Universal bypass of SSL Pinning ... from theory to a full working PoC with LLDB


One of the common questions we get at Ostorlab is,do you support backend scanning if the application uses SSL pinning? The usual follow-up is, how do you bypass SSL pinning ?

Our answer usually goes like this: yes, we do support scanning backend applications with SSL pinning enabled ... However, we don't bypass SSL pinning; we don't need to. The usual reaction is a frown; you are probably doing one just right now.

This article will walk through the technical implementation bypassing SSL pinning without having to, we will explain and showcase the following:

1- Why at Ostorlab we are BIG fans of debugging protocols, 2- Implement a PoC for our local machine and port it to iOS, 3- We will extend the same tool for more advanced dynamic analysis.

All traffic that we intercept is persisted and index and becomes accessible in the Analysis->API menu.

alt text

SSL Pinning bypass on Non-Rooted Non-Jailbroken devices

At Ostorlab, we scan, and analyze mobile applications for security and privacy issues. We do use dynamic analysis among other techniques. We only use real devices throughout the test, and we don't use rooted or jailbroken devices.

We like the fact we can buy off-the-shelves phones and add them with no modification to our test bed, and still be able to perform the battery of tests and checks we need, from IPC interception, filesystem monitoring and even network interception with SSL pinning using custom hardened implementations.

Ostorlab SSL pinning bypass consists of instead of proxying traffic and attempting to find the APIs that handle the SSL pin validation, we rely instead on low level SSL socket interception. This means catching the clear text traffic just before its gets encrypted.

The APIs will vary depending on your target application, but there is only a handful of those, for instance for native applications, SSL_read, SSL_read_ex and SSL_write are the APIs to look for, they exist for Android and iOS and both OpenSSL and BoringSSL.

For Java-based application, Conscrypt SSL stream APIs are a higher level implementation and for Javascript based application, Network.setRequestInterception or Fetch.enable allows native request interception and manipulation.

This approach has several benefits over Proxies as these don't work for:

  • Application that doesn't use the OS proxy (wink, wink, Flutter Apps), public implementations will require using routing to intercept traffic.
  • Proxies may break certain SSL implementations, for instance SSL connection with esoteric protocols or the most recent TLS features may be missing from proxies leading to broken connections. This is a common issue with new iOS releases.
  • Proxies are detectable, and some libraries will check for their presence to avoid interception. These are usually intrusive non-privacy compliant analytics and ads library. See for instance the recent case of the Mintegral ads SDK that used fraud, leaked data and had several RCE vulnerabilities.

Debug Protocols

At Ostorlab we love debug-based instrumentation. These protocols like Remote GDB with clients like LLDB, JDWP or Chrome Remote Debug are very powerful, stable and provide a maintainable solution to dynamic analysis and instrumentation.

While there are already very mature open source projects for memory-based dynamic instrumentation like the venerable Frida or the amazing QBDI, these will require more work to implement powerful features, like stack tracing, symbolication, attribute watching and native code execution.

These protocols will also offer capabilities suitable to the platform, from native traffic interception and modification, for the Chrome Debug Protocol for instance, up to access to cookies, DOM monitoring or cache control. All very useful for automated security testing.

The other main advantage of the debug based instrumentation is out of the box support for the latest and greatest versions with no changes. Often changes to the Android Runtime or the memory layout of objects or ABI (wink, wink, Swift) will require substantial work to understand the new implementation and add its support.

Now debug based instrumentation is not all roses though, debuggers are often significantly slower than memory based instrumentation, hence using them for highly granular performance-sensitive instrumentation, like instruction-based analysis is almost impossible on real life applications.

For performance sensitive instrumentation, hardware-based tracing capabilities like BTS (Branch Trace Store) or Intel PT (Process Tracing) are more suitable and again more maintainable, but not always present.

TLS Interception PoC

The section will walk-through a simple PoC to intercept TLS traffic. We will run the PoC on our local machine and then extend it to run on iOS. The implementation is based on LLDB, and the interception logic will be written in Python 3.

LLDB is a debugger with support for C, Objective-C, C++ and Swift. Is it marketed as a next generation high-performance debugger and reuses components from the larger LLVM project, like the Clang expression parser, which allows for some really powerful analysis capabilities with extreme ease.

LLDB supports scripting in Python and you can find books and open-source code on the topic, my personal recommendation would be Advanced Debugging & Reverse Engineering by Derek Selander. The author maintains a cool set of LLDB commands. Another notable project is Facebook's Chisel.

The concept

To intercept the SSL traffic, the goal is to read requests from SSL_write on entry and read the buf argument with num as a size. For responses, we will intercept SSL_read and SSL_read_ex on exit and read the buf argument with return value as a size.

This simply follows the convention of the API, other APIs will have different patterns, but the strategies are always the same.

Bookkeeping first

While it is usually custom to take an iterative approach to building the PoC starting with a very limited example, I will try to spice things up a bit and try to build an extensible PoC from the start.

To make sure our code is easily extensible, we will use a registry pattern to ease the discovery of hook rules. Registry is a common design pattern. It allows to reverse the discovery process allowing for more maintainable code.

Below is a very simplified Registry class that defines a register annotation.

# registry.py
from collections import defaultdict


class Registry:
    registry = defaultdict(dict)

    @classmethod
    def register_ref(cls, obj, key="__name__"):
        cls.registry[cls.__name__][getattr(obj, key)] = obj
        return obj

    @classmethod
    def iteritems(cls):
        return cls.registry[cls.__name__].items()
# rule_register.py
from .registry import Registry


class DynamicRuleRegistry(Registry):
    pass


def register(f):
    """Registers a class. Can be used a decorator of Rule classes."""
    return DynamicRuleRegistry.register_ref(f(), key='__key__')

The register keeps track of the DynamicRule instance, which are custom class to implement the hook logic.

# dynamic_rule.py
from collections import OrderedDict
from typing import Optional

import lldb

from . import lldb_utils


class DynamicRule:
    """Dynamic rule base implementation."""
    hook_function_name: Optional[str] = None
    hook_module: Optional[str] = None
    hook_signature: OrderedDict = None
    hook_on_entry: bool = False
    hook_on_exit: bool = False

    @property
    def __key__(self):
        return f'{self.__class__.__name__}.{self.hook_function_name}.{id(self)}'

    def on_entry(self, parameters, frame, bp_loc):
        pass

    def on_exit(self, parameters, return_value, frame, bp_loc):
        pass

LLDB offers scripting capabilities with Python. A hello world command script would be:

def __lldb_init_module(debugger, internal_dict):
    debugger.HandleCommand(
        'command script add -f myscript.handle_command hello -h "Hello World Command."')


def handle_command(debugger, command, exe_ctx, result, internal_dict):
    print('Hello LLDB')

The __lldb_init_module declares a new command named hello that triggers the script myscript and function handle_command.

Interception

The instrumentation uses simple breakpoints and defines a callback to execute the hook logic. A global map linking breakpoint ids to rules is kept in breakpoint_map to known what rule to trigger for each breakpoint.

In this example, the rule will execute an on_event method on the DynamicRule class that I previously omitted.

# lldb_monitor.py
from rules import DynamicRuleRegistry, import_all

import_all()

breakpoint_map = {}


def __lldb_init_module(debugger, internal_dict):
    debugger.HandleCommand(
        'command script add -f lldb_monitor.handle_command monitor -h "Init monitoring hooks to trace application."')


def breakpoint_callback(frame, bp_loc, *_):
    global breakpoint_map
    breakpoint_id = bp_loc.GetBreakpoint().GetID()
    rule = breakpoint_map[breakpoint_id]
    rule.on_event(frame, bp_loc, {})
    return False


def handle_command(debugger, command, exe_ctx, result, internal_dict):
    target = debugger.GetSelectedTarget()
    for _, rule in DynamicRuleRegistry.iteritems():
        print(f'setting breakpoint at {rule.hook_function_name} module {rule.hook_module}')
        bp = target.BreakpointCreateByName(rule.hook_function_name, rule.hook_module)
        bp.SetScriptCallbackFunction('lldb_monitor.breakpoint_callback')
        breakpoint_map[bp.GetID()] = rule

The on_event method extracts passed parameters following the hardware calling convention. If the hook_on_exit is set, its extracts the return value as well.

The callback then returns False to continue execution.

# dynamic_rule.py
    def on_event(self, frame, bp_loc, options) -> bool:
        debugger = lldb.debugger
        debugger.SetAsync(False)
        if not self.hook_on_entry and not self.hook_on_exit:
            return False

        values = lldb_utils.parameter_register_values(len(self.hook_signature), debugger, frame)

        if self.hook_on_entry:
            self.on_entry(values, frame, bp_loc)

        if self.hook_on_exit:
            thread = frame.GetThread()
            lldb_utils.stepout_of_frame(debugger, thread, frame)
            current_frame = thread.GetFrameAtIndex(0)

            return_value = lldb_utils.return_register_values(debugger, current_frame)
            self.on_exit(values, return_value, frame, bp_loc)
            lldb_utils.continue_execution(debugger)

        return False

The calling convention for x86_64 is to pass arguments in the rdi, rsi, rdx ... registers and then push them to the stack. Return values are stored in the rax register.

To access the return value, we need to continue execution until the current frame is existed.

# lldb_utils.py
def stepout_of_frame(thread, frame):
    thread.StepOutOfFrame(frame)

For brevity, we will only implement register based reads, if the method has more arguments, it will throw a NotImplementedError exception.

This approach will not work for variadic functions the number of parameters are not known from the start.

# lldb_utils.py
def parameter_register_values(count_params, debugger, frame):
    arch = debugger.GetSelectedTarget().GetTriple()

    register_values = []
    if 'x86_64' in arch:
        regs = ['rdi', 'rsi', 'rdx', 'rcx', 'r8', 'r9']
        for i in range(count_params):
            reg = regs.pop(0)
            if reg is not None:
                register_values.append(read_register(reg, frame))
            else:
                raise NotImplementedError('Unsupported number of params')
    else:
        raise NotImplementedError(f'Unsupported architecture {arch}')

    return register_values


def return_register_values(debugger, frame):
    arch = debugger.GetSelectedTarget().GetTriple()
    if 'x86_64' in arch:
        return read_register('rax', frame)
    else:
        raise NotImplementedError(f'Unsupported architecture {arch}')

To read the register value, we simply access the current frame register attribute and cast the value to int.

# lldb_utils.py
def read_register(register, frame):
    return int(frame.register[register].value, 0)

TLS Hook Rule

Now that we defined the hook logic, we need to implement the hook rule to read the TLS request and response. We define two rules SSLWrite and SSLRead.

The SSLWrite will implement the on_entry method and read buf memory, while the SSLRead will implement the on_exit method to use the return value as size.

# tls_rule.py
from collections import OrderedDict

import lldb

from . import dynamic_rule
from . import lldb_utils
from .rule_register import register


@register
class SSLWriteRule(dynamic_rule.DynamicRule):
    hook_function_name: str = 'SSL_write'
    hook_signature = OrderedDict(
        [('ssl', None), ('buf', None), ('num', dynamic_rule.IntFunctionParam)])
    hook_on_entry: bool = True

    def on_entry(self, parameters, frame, bp_loc):
        memo_address = parameters[1]
        size = parameters[2]
        partial_request = lldb_utils.read_memory(memo_address, size, lldb.debugger)
        extra = b'Request:\n'
        extra += partial_request or b'Empty'
        self._debug(parameters, extra=extra)


@register
class SSLReadExRule(dynamic_rule.DynamicRule):
    hook_function_name: str = 'SSL_read'
    hook_signature = OrderedDict(
        [('ssl', None), ('buf', None), ('num', dynamic_rule.IntFunctionParam)])
    hook_on_exit: bool = True

    def on_exit(self, parameters, return_value, frame, bp_loc):
        memo_address = parameters[1]
        size = return_value
        partial_response = lldb_utils.read_memory(memo_address, size, lldb.debugger)
        extra = b'Response Read:\n'
        extra += partial_response or b'Empty'
        self._debug(parameters, return_value, extra=extra)

To read memory, we simply use the process.ReadMemory method.

# lldb_utils.py
def read_memory(address, size, debugger) -> Optional[bytes]:
    if size <= 0:
        return None

    target = debugger.GetSelectedTarget()
    process = target.GetProcess()

    err = lldb.SBError()
    retval = process.ReadMemory(address, size, err)

    if not err.Success():
        return None
    else:
        return retval

Once the request and response is read, we define a debug to print collected arguments and also show the stack trace. Stack trace simply executes the bt command:

# dynamic_rule.py
    def _debug(self, parameters, return_val=None, extra: bytes=None):
        debug_message = f'CALL to {self.hook_function_name} at {self.hook_module}\n'
        debug_message += lldb_utils.command('sbt', lldb.debugger)
        debug_message += f'With parameters: f{parameters} and return value {return_val}\n'
        debug_message += extra.decode('utf-8', 'ignore') if extra is not None else None
        print(debug_message)

Let run on our machine on curl with the following command. This command will first import lldb_monitor script, call the monitor command to setup the hooks, run the program and finally exit once that command has completed.

For curl, we will force using HTTP/1 to have more readable input, most website nowadays will default to HTTP/2.

lldb -O 'command script import ./lldb_monitor.py' -o 'monitor' -o 'run' -o 'exit' -- curl --http1.1 -v https://google.com
(lldb) command script import ./lldb_monitor.py
(lldb) target create "curl"
Current executable set to 'curl' (x86_64).
(lldb) settings set -- target.run-args  "--http1.1" "https://google.com"
(lldb) monitor
setting breakpoint at SSL_write module None
setting breakpoint at SSL_read module None
(lldb) run
1 location added to breakpoint 6
1 location added to breakpoint 7
CALL to SSL_write at None
frame #0 : 0x7ffff7b91df0 libssl.so.1.1`SSL_write 
frame #1 : 0x7ffff7f70ab9 libcurl.so.4`-[ + 105
frame #2 : 0x7ffff7f26c7f libcurl.so.4`-[ + 63
frame #3 : 0x7ffff7f226dd libcurl.so.4`-[ + 141
frame #4 : 0x7ffff7f24f0b libcurl.so.4`-[ + 6299
frame #5 : 0x7ffff7f43c06 libcurl.so.4`-[ + 2758
frame #6 : 0x7ffff7f44981 libcurl.so.4`curl_multi_perform + 145
frame #7 : 0x7ffff7f3adfb libcurl.so.4`curl_easy_perform + 331
frame #8 : 0x55555556e1d0 curl`-[ + 688
frame #9 : 0x55555555f130 curl`-[ + 336
frame #10: 0x7ffff7d000b3 libc.so.6`__libc_start_main + 243
frame #11: 0x55555555f1fe curl`-[ + 46

With parameters: [93824993656336, 93824993742416, 74] and return value None
Request:
GET / HTTP/1.1
Host: google.com
User-Agent: curl/7.68.0
Accept: */*


CALL to SSL_read at None
frame #0 : 0x7ffff7f732a9 libcurl.so.4`-[ + 105
frame #1 : 0x7ffff7f26d8b libcurl.so.4`-[ + 91
frame #2 : 0x7ffff7f38fad libcurl.so.4`-[ + 1549
frame #3 : 0x7ffff7f43564 libcurl.so.4`-[ + 1060
frame #4 : 0x7ffff7f44981 libcurl.so.4`curl_multi_perform + 145
frame #5 : 0x7ffff7f3adfb libcurl.so.4`curl_easy_perform + 331
frame #6 : 0x55555556e1d0 curl`-[ + 688
frame #7 : 0x55555555f130 curl`-[ + 336
frame #8 : 0x7ffff7d000b3 libc.so.6`__libc_start_main + 243
frame #9 : 0x55555555f1fe curl`-[ + 46

With parameters: [93824993656336, 93824992736672, 102400] and return value 708
Response Read:
HTTP/1.1 301 Moved Permanently
Location: https://www.google.com/
Content-Type: text/html; charset=UTF-8
Date: Tue, 18 May 2021 10:15:26 GMT
Expires: Thu, 17 Jun 2021 10:15:26 GMT
Cache-Control: public, max-age=2592000
Server: gws
Content-Length: 220
X-XSS-Protection: 0
X-Frame-Options: SAMEORIGIN
Alt-Svc: h3-29=":443"; ma=2592000,h3-T051=":443"; ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; ma=2592000; v="46,43"

<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>301 Moved</TITLE></HEAD><BODY>
<H1>301 Moved</H1>
The document has moved
<A HREF="https://www.google.com/">here</A>.
</BODY></HTML>

<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>301 Moved</TITLE></HEAD><BODY>
<H1>301 Moved</H1>
The document has moved
<A HREF="https://www.google.com/">here</A>.
</BODY></HTML>
Process 11329 exited with status = 0 (0x00000000) 

Process 11329 launched: '/usr/bin/curl' (x86_64)
(lldb) exit

alt text

iOS port

Now that we have our PoC running on our machine, time to port to iOS. To run our code on iOS, we will use ios-deploy to enable remote debugging. This first requires installing the application with the get-task-allow attribute set true.

ios-deploy -m --nostart --bundle Payload/OVA.app/

But before we run our command, we need to extend our script to support ARM64 calling convention:

# lldb_utils.py
def parameter_register_values(count_params, debugger, frame):
    arch = debugger.GetSelectedTarget().GetTriple()

    register_values = []
    if 'x86_64' in arch:
        regs = ['rdi', 'rsi', 'rdx', 'rcx', 'r8', 'r9']
        for i in range(count_params):
            reg = regs.pop(0)
            if reg is not None:
                register_values.append(read_register(reg, frame))
            else:
                raise NotImplementedError('Unsupported number of params')
    elif 'arm64' in arch:
        regs = ['x0', 'x1', 'x2', 'x3', 'x4', 'x5', 'x6', 'x7']
        for i in range(count_params):
            reg = regs.pop(0)
            if reg is not None:
                register_values.append(read_register(reg, frame))
            else:
                raise NotImplementedError('Unsupported number of params')
    else:
        raise NotImplementedError(f'Unsupported architecture {arch}')

    return register_values


def return_register_values(debugger, frame):
    arch = debugger.GetSelectedTarget().GetTriple()
    if 'x86_64' in arch:
        return read_register('rax', frame)
    elif 'arm64' in arch:
        return read_register('x0', frame)
    else:
        raise NotImplementedError(f'Unsupported architecture {arch}')

That's it, all the rest still applies.

Extending it to filesystem

To extend our PoC to see for instance what files are read by the application, simply create another rule to monitor open function:

# filesystem_rule.py

@register
class OpenRule(dynamic_rule.DynamicRule):
    hook_function_name: str = 'open'
    hook_signature = OrderedDict(
        [('pathnam', None), ('flags', None), ('mode', None)])
    hook_on_entry = True

    def on_entry(self, parameters, frame, bp_loc):
        path = lldb_utils.read_string(parameters[0])
        self._debug(parameters, extra=f'Opening file {path}'.encode())

And run again:

(lldb) command script import ./lldb_monitor.py
(lldb) target create "curl"
Current executable set to 'curl' (x86_64).
(lldb) settings set -- target.run-args  "--http1.1" "https://google.com"
(lldb) monitor
filesystem
setting breakpoint at open module None
(lldb) run
1 location added to breakpoint 1
CALL to open at None
frame #0 : 0x7ffff7de9e50 libc.so.6`open 
frame #1 : 0x7ffff7d6c196 libc.so.6`_IO_file_open + 38
frame #2 : 0x7ffff7d6c45a libc.so.6`_IO_file_fopen@@GLIBC_2.2.5 + 506
frame #3 : 0x7ffff7d5eb0e libc.so.6`fopen@@GLIBC_2.2.5 + 126
frame #4 : 0x7ffff7d5eaa9 libc.so.6`fopen@@GLIBC_2.2.5 + 25
frame #5 : 0x7ffff793058a libcrypto.so.1.1`BIO_new_file + 26
frame #6 : 0x7ffff796213e libcrypto.so.1.1`-[ + 30
frame #7 : 0x7ffff796374d libcrypto.so.1.1`CONF_modules_load_file + 61
frame #8 : 0x7ffff7f70203 libcurl.so.4`-[ + 35
frame #9 : 0x7ffff7f3a990 libcurl.so.4`-[ + 128
frame #10: 0x55555555f0af curl`-[ + 207
frame #11: 0x7ffff7d000b3 libc.so.6`__libc_start_main + 243
frame #12: 0x55555555f1fe curl`-[ + 46

With parameters: f[93824992676992, 0, 438] and return value None
Opening file /usr/lib/ssl/openssl.cnf
CALL to open at None
frame #0 : 0x7ffff7de9e50 libc.so.6`open 
frame #1 : 0x7ffff7d6c196 libc.so.6`_IO_file_open + 38
frame #2 : 0x7ffff7d6c45a libc.so.6`_IO_file_fopen@@GLIBC_2.2.5 + 506
frame #3 : 0x7ffff7d5eb0e libc.so.6`fopen@@GLIBC_2.2.5 + 126
frame #4 : 0x7ffff7d5eaa9 libc.so.6`fopen@@GLIBC_2.2.5 + 25
frame #5 : 0x55555556f9c7 curl`-[ + 119
frame #6 : 0x55555556e042 curl`-[ + 290
frame #7 : 0x55555555f130 curl`-[ + 336
frame #8 : 0x7ffff7d000b3 libc.so.6`__libc_start_main + 243
frame #9 : 0x55555555f1fe curl`-[ + 46

With parameters: f[93824992677712, 0, 438] and return value None
Opening file /home/asm/.curlrc
CALL to open at None
frame #0 : 0x7ffff7de9e50 libc.so.6`open 
frame #1 : 0x7ffff7d6c196 libc.so.6`_IO_file_open + 38
frame #2 : 0x7ffff7d6c45a libc.so.6`_IO_file_fopen@@GLIBC_2.2.5 + 506
frame #3 : 0x7ffff7d5eb0e libc.so.6`fopen@@GLIBC_2.2.5 + 126
frame #4 : 0x7ffff7d5eaa9 libc.so.6`fopen@@GLIBC_2.2.5 + 25
frame #5 : 0x7ffff793058a libcrypto.so.1.1`BIO_new_file + 26
frame #6 : 0x7ffff796213e libcrypto.so.1.1`-[ + 30
frame #7 : 0x7ffff796374d libcrypto.so.1.1`CONF_modules_load_file + 61
frame #8 : 0x7ffff7963a10 libcrypto.so.1.1`-[ + 64
frame #9 : 0x7ffff79fa234 libcrypto.so.1.1`-[ + 20
frame #10: 0x7ffff7edd47f libpthread.so.0`__pthread_once_slow + 191
frame #11: 0x7ffff7a6578d libcrypto.so.1.1`CRYPTO_THREAD_run_once + 13
frame #12: 0x7ffff79fa8b8 libcrypto.so.1.1`OPENSSL_init_crypto + 808
frame #13: 0x7ffff7b8f575 libssl.so.1.1`OPENSSL_init_ssl + 53
frame #14: 0x7ffff7b934a2 libssl.so.1.1`SSL_CTX_new + 34
frame #15: 0x7ffff7f7368e libcurl.so.4`-[ + 414
frame #16: 0x7ffff7f7513f libcurl.so.4`-[ + 383
frame #17: 0x7ffff7f75faf libcurl.so.4`-[ + 95
frame #18: 0x7ffff7f21296 libcurl.so.4`-[ + 22
frame #19: 0x7ffff7f22d13 libcurl.so.4`-[ + 387
frame #20: 0x7ffff7f438ed libcurl.so.4`-[ + 1965
frame #21: 0x7ffff7f44981 libcurl.so.4`curl_multi_perform + 145
frame #22: 0x7ffff7f3adfb libcurl.so.4`curl_easy_perform + 331
frame #23: 0x55555556e1d0 curl`-[ + 688
frame #24: 0x55555555f130 curl`-[ + 336
frame #25: 0x7ffff7d000b3 libc.so.6`__libc_start_main + 243
frame #26: 0x55555555f1fe curl`-[ + 46

With parameters: f[93824992696608, 0, 438] and return value None
Opening file /usr/lib/ssl/openssl.cnf
CALL to open at None
frame #0 : 0x7ffff7de9e50 libc.so.6`open 
frame #1 : 0x7ffff7d6c196 libc.so.6`_IO_file_open + 38
frame #2 : 0x7ffff7d6c45a libc.so.6`_IO_file_fopen@@GLIBC_2.2.5 + 506
frame #3 : 0x7ffff7d5eb0e libc.so.6`fopen@@GLIBC_2.2.5 + 126
frame #4 : 0x7ffff7d5eaa9 libc.so.6`fopen@@GLIBC_2.2.5 + 25
frame #5 : 0x7ffff793058a libcrypto.so.1.1`BIO_new_file + 26
frame #6 : 0x7ffff7a7130c libcrypto.so.1.1`X509_load_cert_crl_file + 60
frame #7 : 0x7ffff7a7147a libcrypto.so.1.1`-[ + 74
frame #8 : 0x7ffff7a742a3 libcrypto.so.1.1`X509_STORE_load_locations + 67
frame #9 : 0x7ffff7f742b6 libcurl.so.4`-[ + 3526
frame #10: 0x7ffff7f7513f libcurl.so.4`-[ + 383
frame #11: 0x7ffff7f75faf libcurl.so.4`-[ + 95
frame #12: 0x7ffff7f21296 libcurl.so.4`-[ + 22
frame #13: 0x7ffff7f22d13 libcurl.so.4`-[ + 387
frame #14: 0x7ffff7f438ed libcurl.so.4`-[ + 1965
frame #15: 0x7ffff7f44981 libcurl.so.4`curl_multi_perform + 145
frame #16: 0x7ffff7f3adfb libcurl.so.4`curl_easy_perform + 331
frame #17: 0x55555556e1d0 curl`-[ + 688
frame #18: 0x55555555f130 curl`-[ + 336
frame #19: 0x7ffff7d000b3 libc.so.6`__libc_start_main + 243
frame #20: 0x55555555f1fe curl`-[ + 46

With parameters: f[93824992691248, 0, 438] and return value None
Opening file /etc/ssl/certs/ca-certificates.crt
<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>301 Moved</TITLE></HEAD><BODY>
<H1>301 Moved</H1>
The document has moved
<A HREF="https://www.google.com/">here</A>.
</BODY></HTML>
Process 11920 exited with status = 0 (0x00000000) 

Process 11920 launched: '/usr/bin/curl' (x86_64)
(lldb) exit

Summary

So let's sum up:

  • We intercepted traffic, without a proxy, and without disabling SSL pinning on a non-Jailbroken device.
  • We built a PoC for x86_64 and for ARM64 using LLDB and Python
  • We extended it to perform other dynamic analysis instrumentation
  • We structured our PoC in an extensible and maintainable way using the registry pattern.

I hope you found this useful.