A short blog on how to bypass certificate pinning on the ProtonVPN macOS app using Proxyman and Frida. ProtonVPN is a VPN service operated by the Swiss company Proton AG. The service features a cilent application that users can install on various platforms, such as Android TV and Chromebook.

I’ve personally had a proton email account for a quite a while now, but never really looked into the VPN service. I was mainly curious at how the macOS app communicates with the backend, and what API end-points it talks to.

First Attempt

At first I tried to use the HTTP_PROXY environment variable within a Bash shell which was set to my local Burp Proxy listener 127.0.0.1:8080 and then starting the ProtonVPN binary manually, located at /Applications/ProtonVPN.app/Contents/MacOS/ProtonVPN.

Output produced when running the binary manually:

➜  ~ /Applications/ProtonVPN.app/Contents/MacOS/ProtonVPN
objc[67247]: Class _TtC5Timer26TimerFactoryImplementation is implemented in both /Applications/ProtonVPN.app/Contents/Frameworks/Timer_DA7C99285C6_PackageProduct.framework/Versions/A/Timer_DA7C99285C6_PackageProduct (0x103194860) and /Applications/ProtonVPN.app/Contents/Frameworks/vpncore.framework/Versions/A/vpncore (0x105acd890). One of the two will be used. Which one is undefined.
objc[67247]: Class _TtC5Timer29BackgroundTimerImplementation is implemented in both /Applications/ProtonVPN.app/Contents/Frameworks/Timer_DA7C99285C6_PackageProduct.framework/Versions/A/Timer_DA7C99285C6_PackageProduct (0x1031948f0) and /Applications/ProtonVPN.app/Contents/Frameworks/vpncore.framework/Versions/A/vpncore (0x105acd920). One of the two will be used. Which one is undefined.
objc[67247]: Class _TtC14KeychainAccess8Keychain is implemented in both /Applications/ProtonVPN.app/Contents/Frameworks/vpncore.framework/Versions/A/vpncore (0x105acc7d0) and /Applications/ProtonVPN.app/Contents/Frameworks/KeychainAccess.framework/Versions/A/KeychainAccess (0x102a105b8). One of the two will be used. Which one is undefined.
2022-12-21 10:44:03.080 ProtonVPN[67247:1556413] Could not find image named 'VPNWordmarkNoBackground'.
2022-12-21 10:44:03.096 ProtonVPN[67247:1556413] Could not find image named 'ic-exclamation-circle-filled'.

However, this was unsuccessful as the app just ignored the proxy settings.

Second Attempt

My next attempt was to use a tool called Proxyman. Proxyman is able to proxy network traffic from applications to its own proxy listener. Just like with Burp Suite, it too requires a custom Certificate Authority (CA) installed on the system to analyse encrypted HTTPS traffic.

Launching ProtonVPN while Proxyman is running, the app still showed an error while trying to connect and log-in. A standard general error message “Proton servers are unreachable…” wasn’t very helpful:

But when looking at the Proxyman error messages it was immediately clear that certificate pinning was in place. These error messages were extremely helpful in getting around the protections. I got Internal Error responses for all the HTTPS requests.

The error message:

SSL Handshake Failed
handshakeFailed(NIOSSL.BoringSSLError.sslError([Error:  EOF during handshake]))

This was a key piece of information that would lead me to discover the implementation of TLS within ProtonVPN which was called BoringSSL or SwiftNIO SSL library. From there I started to look for how this could be bypassed.

BoringSSL/SwiftNIO SSL

SwiftNIO SSL is a Swift package that contains an implementation of TLS based on BoringSSL. From the error message above it appears ProtonVPN makes use of it.

Verifying the use of BoringSSL

NOTE: On macOS systems you need to Disable SIP to attach to other processes with Frida. You can do this by holding the power button until you reach the recovery utility, and then get to the terminal and type: csrutil disable and reboot.

To confirm the ProtonVPN app actually uses the library, I used a Frida script (just the JavaScript part) to enumerate modules which are loaded into memory when the app is executed. The script simply prints to standard output, which I also save to a text file.

Get ProtonVPN app process PID with frida-ps and grep.

And then run frida -p 6074 -l enum-modules.js -o protonvpn-modules.txt

➜ frida -p 6074 -l enum-modules.js -o protonvpn-modules.txt
...
Module name: libCGInterfaces.dylib - Base Address: 0x1a9330000
Module name: RawCamera - Base Address: 0x1abb52000
Module name: AppSSOCore - Base Address: 0x18ffc2000
Module name: libboringssl.dylib - Base Address: 0x188ae4000
Module name: libusrtcp.dylib - Base Address: 0x191afa000
Module name: libquic.dylib - Base Address: 0x1a423e000
Module name: liblog_network.dylib - Base Address: 0x1a4e15000
...

As seen above the libboringssl.dylib module is loaded at the address 0x188ae4000.

You can also use frida-trace to trace function calls targeting the libboringssl.dylib module. For example: frida-trace -p 67505 -i 'libboringssl.dylib!*psk*' which outputs:

Instrumenting...
SSL_CTX_set_psk_server_callback: Loaded handler at "/Users/naz/__handlers__/libboringssl.dylib/SSL_CTX_set_psk_server_callback.js"
SSL_CTX_use_psk_identity_hint: Loaded handler at "/Users/naz/__handlers__/libboringssl.dylib/SSL_CTX_use_psk_identity_hint.js"
SSL_set_psk_server_callback: Loaded handler at "/Users/naz/__handlers__/libboringssl.dylib/SSL_set_psk_server_callback.js"
SSL_CTX_set_psk_client_callback: Loaded handler at "/Users/naz/__handlers__/libboringssl.dylib/SSL_CTX_set_psk_client_callback.js"
SSL_set_psk_client_callback: Loaded handler at "/Users/naz/__handlers__/libboringssl.dylib/SSL_set_psk_client_callback.js"
boringssl_context_set_psk_identity_hint: Loaded handler at "/Users/naz/__handlers__/libboringssl.dylib/boringssl_context_set_psk_identity_hint.js"
SSL_get_psk_identity_hint: Loaded handler at "/Users/naz/__handlers__/libboringssl.dylib/SSL_get_psk_identity_hint.js"
SSL_get_psk_identity: Loaded handler at "/Users/naz/__handlers__/libboringssl.dylib/SSL_get_psk_identity.js"
SSL_use_psk_identity_hint: Loaded handler at "/Users/naz/__handlers__/libboringssl.dylib/SSL_use_psk_identity_hint.js"
Started tracing 9 functions. Press Ctrl+C to stop.

To trace all the functions in a module: frida-trace 67505 -I 'libboringssl.dylib'

Third Attempt (bypass with Frida)

A quick Google search on how to bypass BoringSSL reveals this handy little Frida script by @apps3c. Although it says it’s made for iOS platforms, the code can also be used on macOS applications without any extra modifications.

This is because the libboringssl.dylib module is the almost (if not) identical to the macOS implementation which uses the same underlying function names that we are trying to hook.

At a high level the Frida script works like this:

  1. Check libboringssl.dylib module is loaded (otherwise, load it manually).
  2. Initialise and set a few custom variables.
  3. Create a new implementation of specific SSL functions.
  4. Wait and hook these functions and replace the return value.

Running

Save the Frida script to e.g. BoringSSL-bypass.js and run ProtonVPN using Frida:

frida /Applications/ProtonVPN.app/Contents/MacOS/ProtonVPN -l BoringSSL-bypass.js

Result

And the result is we can now view encrypted HTTPS traffic with the Proxyman app!

A few API end-points and URLs observed:

https://api.protonvpn.ch/vpn/countries/count
https://api.protonvpn.ch/vpn/featureconfig/dynamic-bug-reports
https://api.protonvpn.ch/domains/available?Type=login
https://protonvpn.com/download/macos-update3.xml
https://api.protonvpn.ch/auth/info
https://api.protonvpn.ch/auth
https://api.protonvpn.ch/auth/2fa
https://api.protonvpn.ch/vpn/location
https://api.protonvpn.ch/vpn/streamingservices
https://api.protonvpn.ch/vpn
https://api.protonvpn.ch/vpn/loads
https://api.protonvpn.ch/vpn/sessioncount
https://api.protonvpn.ch/auth/v4/sessions/forks
https://api.protonvpn.ch/vpn/v1/partners?WithImageScale=2
https://api.protonvpn.ch/vpn/v2/clientconfig
https://api.protonvpn.ch/vpn/logicals?WithTranslations=true&WithPartnerLogicals=1
https://api.protonvpn.ch/core/v4/notifications?FullScreenImageSupport=PNG&FullScreenImageWidth=2880.0&FullScreenImageHeight=1750.0
https://api.protonvpn.ch/vpn/v1/certificate

Appendix

Enumerate modules script

// Source: https://github.com/poxyran/misc/blob/master/frida-enumerate-modules.py
Process.enumerateModules({
    onMatch: function(module){
        console.log('Module name: ' + module.name + " - " + "Base Address: " + module.base.toString());
    }, 
    onComplete: function(){}
});

iOS 13 pinning bypass script

/* Description: iOS 13 SSL Bypass based on https://codeshare.frida.re/@machoreverser/ios12-ssl-bypass/ and https://github.com/nabla-c0d3/ssl-kill-switch2
    Source: https://codeshare.frida.re/@federicodotta/ios13-pinning-bypass/
 *  Author: @apps3c
 */

try {
	Module.ensureInitialized("libboringssl.dylib");
} catch(err) {
	console.log("libboringssl.dylib module not loaded. Trying to manually load it.")
	Module.load("libboringssl.dylib");	
}

var SSL_VERIFY_NONE = 0;
var ssl_set_custom_verify;
var ssl_get_psk_identity;	

ssl_set_custom_verify = new NativeFunction(
	Module.findExportByName("libboringssl.dylib", "SSL_set_custom_verify"),
	'void', ['pointer', 'int', 'pointer']
);

/* Create SSL_get_psk_identity NativeFunction 
* Function signature https://commondatastorage.googleapis.com/chromium-boringssl-docs/ssl.h.html#SSL_get_psk_identity
*/
ssl_get_psk_identity = new NativeFunction(
	Module.findExportByName("libboringssl.dylib", "SSL_get_psk_identity"),
	'pointer', ['pointer']
);

/** Custom callback passed to SSL_CTX_set_custom_verify */
function custom_verify_callback_that_does_not_validate(ssl, out_alert){
	return SSL_VERIFY_NONE;
}

/** Wrap callback in NativeCallback for frida */
var ssl_verify_result_t = new NativeCallback(function (ssl, out_alert){
	custom_verify_callback_that_does_not_validate(ssl, out_alert);
},'int',['pointer','pointer']);

Interceptor.replace(ssl_set_custom_verify, new NativeCallback(function(ssl, mode, callback) {
	//  |callback| performs the certificate verification. Replace this with our custom callback
	ssl_set_custom_verify(ssl, mode, ssl_verify_result_t);
}, 'void', ['pointer', 'int', 'pointer']));

Interceptor.replace(ssl_get_psk_identity, new NativeCallback(function(ssl) {
	return "notarealPSKidentity";
}, 'pointer', ['pointer']));
	
console.log("[+] Bypass successfully loaded ");