Security research

How to extract TLS secrets from Android apps using Frida and Wireshark

Publisher
Pentest-Tools.com
Updated at

Ever find yourself facing a wall of encrypted traffic when testing an Android app? You know, the kind where SSL pinning, proprietary encryption, and custom network protocols seem to conspire against you? 

Many of us have run into situations where dealing with SIP, RTSP, or another protocol that doesn't play well with standard interception tools is a bit of a headache because traditional man-in-the-middle (MITM) techniques just don't quite cut it. 

If you've ever felt that frustration, trying to see what's really happening within an app's network communication during a vulnerability assessment, then this guide is for you. Let’s explore a technique that helps you gain deeper insight, even when faced with those tough encryption hurdles.

We'll walk through how to leverage Frida to hook into BoringSSL, extract those TLS secrets, and then use Wireshark to decrypt the network traffic. 

Whether you're trying to bypass SSL pinning, peek at those hidden API calls, or just see if there's any sensitive data in transit, this approach can get you the access and visibility you need. 

What is an SSLKEYLOGFILE?

An SSLKEYLOGFILE logs the secrets used in a TLS session, enabling Wireshark to decrypt the encrypted traffic from a capture file. 

Mozilla and other organizations provide documentation on this format. With the right keys, you can analyze TLS traffic without modifying the app or breaking SSL pinning. You can learn more about this format from this internal draft and other Firefox source docs.

Before you can run, you must walk - capturing Android traffic

First, we need to capture the encrypted traffic. 

Since Android doesn’t come with tcpdump preinstalled, you’ll need to cross-compile it and transfer the binary to your Android device or emulator.

To enable cross-compilation, first install the necessary packages:

sudo dpkg --add-architecture arm64
sudo apt update

Then, you can follow the instructions available here, or just use our script. If you use our script, the binary will be available in build_output/tcpdumpX/.

#!/bin/bash

echo "Installing required packages..."

sudo apt install -y gcc-aarch64-linux-gnu byacc flex libssl-dev:arm64

echo "Creating output directory..."

mkdir build_output && cd build_output

echo "Downloading tcp dump and libpcap releases..."

export TCPDUMP=4.99.5
export LIBPCAP=1.10.5

wget https://www.tcpdump.org/release/tcpdump-$TCPDUMP.tar.gz
wget https://www.tcpdump.org/release/libpcap-$LIBPCAP.tar.gz

tar zxvf tcpdump-$TCPDUMP.tar.gz
tar zxvf libpcap-$LIBPCAP.tar.gz

echo "Compiling libpcap..."

export CC=aarch64-linux-gnu-gcc
cd libpcap-$LIBPCAP
./configure --host=arm-linux --with-pcap=linux
make
cd ..

echo "Compiling tcpdump..."

cd tcpdump-$TCPDUMP
export ac_cv_linux_vers=2
export CFLAGS=-static
export CPPFLAGS=-static
export LDFLAGS=-static

./configure --host=arm-linux --disable-ipv6
make

echo "Striping the binary..."

aarch64-linux-gnu-strip tcpdump

Once compiled, transfer tcpdump to the Android device and verify it is working. On Android Studio's emulator, use eth0 as the default interface. If no data is captured, make sure you’re using the right interface.

./tcpdump -i eth0

Pinpoint the SSL library and dig for important elements

To keep things simpler, we’re using a dummy application for this guide. 

The app only performs requests using TLS1.1, TLS1.2, and TLS1.3, and uses the okhttp3 library. 

TLS1.1, and TLS1.2 buttons make requests to tls-v1-1.badssl.com, and tls-v1-2.badssl.com respectively. The requests to tls-v1-2.badssl.com are made to the /$counter endpoint, where $counter is the number of times a TLS1.2 request was made. 

The TLS1.3 button makes a request to Pentest-Tools.com, while the TLS1.3 x2 button makes two asynchronous requests to Pentest-Tools.com, one to the request1 endpoint and one to the request2 endpoint. 

You can find the app’s source code here.


First, we need to check which library the app is using to handle TLS. 


Most of the time, apps use either Java's native SSL API, or libboringssl


If an app is using Java's native SSL API, you should see imports such as javax.net.ssl.SSLSocket

If the app is using a shared library to handle SSL, we can find out which one it is by looking at the memory of the process to see which shared objects it imports.

emu64a:/ $ ps -ef | grep myapplication # get the PID of the process
u0_a177       5837   440 1 11:52:02 ?     00:00:04 com.example.myapplication
root          7506  7299 4 12:01:25 /debug_ramdisk/.magisk/pts/0 00:00:00 grep myapplication

emu64a:/ $ cat /proc/5837/maps | grep -E ".*ssl.*so" # search possible ssl libraries
7880f46000-7880f6d000 r--p 00000000 fe:0e 175                            /apex/com.android.conscrypt/lib64/libssl.so
7880f6e000-7880fad000 r-xp 00028000 fe:0e 175                            /apex/com.android.conscrypt/lib64/libssl.so
7880fae000-7880fb2000 r--p 00068000 fe:0e 175                            /apex/com.android.conscrypt/lib64/libssl.so
7880fb2000-7880fb3000 rw-p 0006c000 fe:0e 175                            /apex/com.android.conscrypt/lib64/libssl.so

Even though the actual shared object is called libssl.so, we discovered it’s actually boringssl. We did this by iterating through all the symbols of the library and checking for any specific symbols only boringssl uses. 

Identifying BoringSSL is important because it determines which internal functions and structures we need to hook into to extract TLS secrets effectively. 

Here’s the simple Frida script we can use for this:

Module.findBaseAddress("libssl.so");

Module.enumerateSymbols("libssl.so").forEach(function(s) {
    console.log("[*] Found symbol: " + s.name + " at " + s.address);
});


Now that we know exactly which library the app is using, we need to find a way to extract the relevant secrets. 

An easy way to do this is to hook to certain functions that handle the secrets and then read from their addresses. That’s fairly straightforward to do since we already have their addresses from the script above.

In boringssl there are two such functions: tls13_derive_application_secrets (it is only used for TLS1.3 as the name suggests) and ssl_log_secret.


The tls13_derive_application_secrets function is short and sweet. It receives an SSL_HANDSHAKE struct as an argument, which holds all the secrets we need. This way, we can extract them to decrypt TLS1.3 using only one hook - and the best part is we can have these secrets grouped by their session.

bool tls13_derive_application_secrets(SSL_HANDSHAKE *hs) {
  SSL *const ssl = hs->ssl;
  if (!derive_secret(hs, &hs->client_traffic_secret_0,
                     kTLS13LabelClientApplicationTraffic) ||
      !ssl_log_secret(ssl, "CLIENT_TRAFFIC_SECRET_0",
                      hs->client_traffic_secret_0) ||
      !derive_secret(hs, &hs->server_traffic_secret_0,
                     kTLS13LabelServerApplicationTraffic) ||
      !ssl_log_secret(ssl, "SERVER_TRAFFIC_SECRET_0",
                      hs->server_traffic_secret_0) ||
      !derive_secret(hs, &ssl->s3->exporter_secret, kTLS13LabelExporter) ||
      !ssl_log_secret(ssl, "EXPORTER_SECRET", ssl->s3->exporter_secret)) {
    return false;
  }

  return true;
}


Now, some apps such as Google Chrome, and Mozilla Firefox use ssl_log_secret, allowing users to generate SSLKEYLOGFILEs just by setting an environment variable


However, hardened Android apps don't have this feature (sadly for us, pentesters). 


The good news is that ssl_log_secret is called no matter if logging is enabled or not. This function then has the responsibility to check whether it should log the secret or not. For our use case, this means we can leak every secret using it.

bool ssl_log_secret(const SSL *ssl, const char *label,
                    Span<const uint8_t> secret) {
  if (ssl->ctx->keylog_callback == NULL) {
    return true;
  }
[snip]
}

For any TLS version, we also need to get what's called a Client Random - the 32 bytes random value from the Client Hello message. This might be confusing since we have another value with the same name for  TLS1.0-TLS1.2.

Luckily for us, getting this value is very easy. 


boringssl exports a function called SSL_get_client_random that does exactly what we need: it returns the client random value. This means we can simply call this function to get the Client Random value.

size_t SSL_get_client_random(const SSL *ssl, uint8_t *out, size_t max_out) {
  if (max_out == 0) {
    return sizeof(ssl->s3->client_random);
  }
  if (max_out > sizeof(ssl->s3->client_random)) {
    max_out = sizeof(ssl->s3->client_random);
  }
  OPENSSL_memcpy(out, ssl->s3->client_random, max_out);
  return max_out;
}

Hooking to functions and extracting the SSL secrets we need


We decided to use tls13_derive_application_secrets to extract the TLS1.3 secrets, and ssl_log_secret for TLS1.1 and TLS1.2. 


The are two reasons for this:

  • If we want the secrets for TLS1.3 grouped by session, it’s much easier to do it this way. Using ssl_log_secret would mean writing logic in JavaScript that defines an object to hold all the secrets and then correlates the ssl pointer for each session. For TLS1.1 and 1.2 we only have one secret, so this wouldn’t be a problem;

  • If ssl_log_secret will be changed in the future to alter this behavior, the logging for TLS1.3 would still work. ssl_log_secret would be easier to change (it only requires moving the initial if clause outside the function, before calling it), while tls13_derive_application_secrets can't be changed.

First, we need to get the addresses of tls13_derive_application_secrets and ssl_log_secret.

const needed_functions = [
    "tls13_derive_application_secrets",
    "ssl_log_secret",
];
let func_collection = {
    "tls13_derive_application_secrets": null,
    "ssl_log_secret": null,
};

Module.findBaseAddress("libssl.so");
Module.enumerateSymbols("libssl.so").forEach(function(s) {
    const matchedFunction = needed_functions.find(str => s.name.includes(str));
    if (matchedFunction) {
        func_collection[matchedFunction] = s.address;
    }
});


ssl_log_secret receives the secret we’re interested in as an argument. Since we know what length a secret should have, it’s easy to simply read it from that address. 


However, tls13_derive_application_secrets receives a struct, meaning we need to find the offsets the secrets are stored at in the struct ourselves.

This is the definition of the SSL_HANDSHAKE struct:

struct SSL_HANDSHAKE {
  explicit SSL_HANDSHAKE(SSL *ssl);
  ~SSL_HANDSHAKE();
  static constexpr bool kAllowUniquePtr = true;

  // ssl is a non-owning pointer to the parent |SSL| object.
  SSL *ssl;

  // config is a non-owning pointer to the handshake configuration.
  SSL_CONFIG *config;

  // wait contains the operation the handshake is currently blocking on or
  // |ssl_hs_ok| if none.
  enum ssl_hs_wait_t wait = ssl_hs_ok;

  // state is the internal state for the TLS 1.2 and below handshake. Its
  // values depend on |do_handshake| but the starting state is always zero.
  int state = 0;

  // tls13_state is the internal state for the TLS 1.3 handshake. Its values
  // depend on |do_handshake| but the starting state is always zero.
  int tls13_state = 0;

  // min_version is the minimum accepted protocol version, taking account both
  // |SSL_OP_NO_*| and |SSL_CTX_set_min_proto_version| APIs.
  uint16_t min_version = 0;

  // max_version is the maximum accepted protocol version, taking account both
  // |SSL_OP_NO_*| and |SSL_CTX_set_max_proto_version| APIs.
  uint16_t max_version = 0;

  InplaceVector<uint8_t, SSL_MAX_MD_SIZE> secret;
  InplaceVector<uint8_t, SSL_MAX_MD_SIZE> early_traffic_secret;
  InplaceVector<uint8_t, SSL_MAX_MD_SIZE> client_handshake_secret;
  InplaceVector<uint8_t, SSL_MAX_MD_SIZE> server_handshake_secret;
  InplaceVector<uint8_t, SSL_MAX_MD_SIZE> client_traffic_secret_0;
  InplaceVector<uint8_t, SSL_MAX_MD_SIZE> server_traffic_secret_0;
  InplaceVector<uint8_t, SSL_MAX_MD_SIZE> expected_client_finished;
  [snip]


We are interested in the following:

  • Secret

  • Client_handshake_secret

  • Server_handshake_secret

  • Client_traffic_secret_0

  • server_traffic_secret_0


Before them, we have two pointers, an enum, two integers, and two 16 byte values. 

The following table lists the offsets where we can find each value:

Value

Offset

secret

0x20

client_handshake_secret

0x80

server_handshake_secret

0xB0

client_traffic_secret_0

0xE0

server_traffic_secret_0

0x110

To cross check that we calculated everything correctly, we can also check the values stored at 0x1C and 0x1E

These are the min_version and max_version values and should have values such as 0x0301, 0x0302, 0x0303, 0x0304, as defined in include/openssl/ssl.h.

#define TLS1_VERSION 0x0301
#define TLS1_1_VERSION 0x0302
#define TLS1_2_VERSION 0x0303
#define TLS1_3_VERSION 0x0304


Finally, we want to define a way to call SSL_get_client_random from Frida at runtime.

var SSL_get_client_random = new NativeFunction(Module.findExportByName("libssl.so", "SSL_get_client_random"), 'int', ['pointer', 'pointer', 'int']);

function get_client_random(ssl) {
    var client_random_buf = Memory.alloc(32);
    var len = SSL_get_client_random(ssl, client_random_buf, 32);
    
    if (len === 32) {
        return Memory.readByteArray(client_random_buf, 32);
    } else {
        console.log("[ERROR] Failed to get client random!");
        return null;
    }
}

Now that we have a way to also extract the Client Random value, we can hook to the functions and extract the secrets.


// generate random stub for log file name
var random_stub = Math.random().toString(36).substring(2, 15);

function validate_secrets(client_random, client_handshake_secret, server_handshake_secret, client_traffic_secret_0, server_traffic_secret_0, exporter_secret) {
    if (client_handshake_secret.length !== 64 || server_handshake_secret.length !== 64 || client_traffic_secret_0.length !== 64 || server_traffic_secret_0.length !== 64 || exporter_secret.length !== 64 || client_random.length !== 64) {
        console.log("[ERROR] Invalid secret length!");
        return false;
    }
    return true;
}

Interceptor.attach(func_collection["tls13_derive_application_secrets"], {
    onEnter: function (args) {
        this.hs = args[0]; // SSL_HANDSHAKE *hs
    },
    onLeave: function (retval) {
        function toHexString(byteArray) {
            if (!byteArray || byteArray.byteLength === 0) {
                console.log("[ERROR] Failed to read byte array or byte array is empty!");
                return "";
            }
            return Array.from(new Uint8Array(byteArray), byte => ('0' + (byte & 0xFF).toString(16)).slice(-2)).join('');
        }

        var sslPtr = Memory.readPointer(this.hs);
        var exporter_secret = toHexString(Memory.readByteArray(this.hs.add(0x20), 48)).slice(16, -16);
        var client_handshake_secret = toHexString(Memory.readByteArray(this.hs.add(0x80), 48)).slice(16, -16);
        var server_handshake_secret = toHexString(Memory.readByteArray(this.hs.add(0xB0), 48)).slice(16, -16);
        var client_traffic_secret_0 = toHexString(Memory.readByteArray(this.hs.add(0xE0), 48)).slice(16, -16);
        var server_traffic_secret_0 = toHexString(Memory.readByteArray(this.hs.add(0x110), 48)).slice(16, -16);
        var client_random = toHexString(get_client_random(sslPtr));

        if (!validate_secrets(client_random, client_handshake_secret, server_handshake_secret, client_traffic_secret_0, server_traffic_secret_0, exporter_secret)) {
            return;
        }

        const logData = [
            "CLIENT_HANDSHAKE_TRAFFIC_SECRET " + client_random + " " + client_handshake_secret,
            "SERVER_HANDSHAKE_TRAFFIC_SECRET " + client_random + " " + server_handshake_secret,
            "CLIENT_TRAFFIC_SECRET_0 " + client_random + " " + client_traffic_secret_0,
            "SERVER_TRAFFIC_SECRET_0 " + client_random + " " + server_traffic_secret_0,
            "EXPORTER_SECRET " + client_random + " " + exporter_secret
        ].join('\n');

        var f = new File("/data/local/tmp/log_" + random_stub + ".txt", "a");
        f.write(logData + "\n");
        f.flush();
        f.close();
    }
});

Interceptor.attach(func_collection["ssl_log_secret"], {
    onEnter: function (args) {
        function toHexString(byteArray) {
            if (!byteArray || byteArray.byteLength === 0) {
                console.log("[ERROR] Failed to read byte array or byte array is empty!");
                return "";
            }
            return Array.from(new Uint8Array(byteArray), byte => ('0' + (byte & 0xFF).toString(16)).slice(-2)).join('');
        }
        var labelPtr = args[1];
        var label = Memory.readCString(labelPtr);
        if (label !== "CLIENT_RANDOM") {
            return;
        }

        var sslPtr = args[0];

        var secretPtr = args[2];
        var secret = Memory.readByteArray(secretPtr, 48);

        var client_random = toHexString(get_client_random(sslPtr));

        var f = new File("/data/local/tmp/log_" + random_stub + ".txt", "a");
        f.write(label + " " + client_random + " " + toHexString(secret) + "\n");
        f.flush();
        f.close();
    }
});


You can find the full script here.

Testing the script to generate the SSLKEYLOG file

Now, let's test the script.

First, we need to make sure the script can write to /data/local/tmp. That’s because we’re using the /data/local/tmp directory to write logfile.

emu64a:/data/local/tmp $ chmod -R 777 /data/local/tmp
emu64a:/data/local/tmp $ setenforce 0

Then, we start listening using tcpdump.

emu64a:/data/local/tmp $ ./tcpdump -i eth0 -w capture_1.pcap

And finally, we can start our app using Frida.

frida -U -f com.example.myapplication -l tls_keylogging.js


After we generate the traffic, we stop the tcpdump capture, and exit the app. We want to find the log file and make it readable by everyone so we can pull it on our local machine.

emu64a:/data/local/tmp $ ls -lah | grep log
emu64a:/data/local/tmp $ chmod -R 777 log_3119554775241.txt

After pulling both the capture file and the log file to our local machine, we’re ready to finally decrypt the traffic.

Putting it all together: decrypting the traffic in Wireshark


After opening Wireshark, let’s go to the Settings or Preferences menu.

We want to go to Protocols and scroll all the way to TLS and select it.

Finally, we want to select our log file as the (Pre)-Master-Secret log file.

Now, we simply open our capture file and, after all this work, we should see the decrypted traffic.

You can see the three requests we made to our server.

This is how the traffic would look like without using the SSLKEYLOGFILE.


The same thing goes for decrypting TLS1.2 traffic, but we'll showcase this with a video.

Demo: extract TLS secrets from Android apps using Frida & Wireshark


Going beyond traffic analysis - how Frida opens doors to memory forensics 

With this powerful technique to dissect encrypted Android traffic, you’re ready to deal with SSL pinning and the limitations of standard tools. Hope this helps you expose previously hidden network communications, be it HTTP, SIP, or custom protocols by using Frida and Wireshark.

We’ve found this method to be invaluable for vulnerability assessments and mobile app penetration tests, but remember: real-world implementations can vary significantly. 


Success depends on how well you understand the target's specific SSL/TLS implementation and how effectively you adapt your approach. 

What’s interesting is that, beyond traffic analysis, Frida opens doors to memory forensics, potentially revealing session tokens, API keys, and encrypted payloads.

Eager to do more? Why not:

  • Try this method on various Android applications, especially hardened apps with custom SSL implementations.

  • Modify the Frida script to extract additional secrets or automate key logging.

  • Contribute to the research - if you find new ways to improve TLS decryption, share your findings with the community!

This guide has handed you a key to decrypting Android traffic, but the locks are always changing in this constantly evolving ecosystem of mobile threats and defenses. 

So what will you unlock next? 


Will you refine this technique, discover new vulnerabilities, or even contribute to the very defenses you've bypassed? 

The responsibility to use this knowledge wisely is yours. 


Don't just follow the steps; question them.

Push the boundaries. 

And when you find something new, share it. 


Because in this field, the only constant is the drive to learn – and the impact you choose to make.

Get fresh security research

In your inbox. (No fluff. Actionable stuff only.)

I can see your vulns image

Discover our ethical hacking toolkit and all the free tools you can use!

Create free account

Footer

© 2013-2025 Pentest-Tools.com

Join over 45,000 security specialists to discuss career challenges, get pentesting guides and tips, and learn from your peers. Follow us on LinkedIn!

Expert pentesters share their best tips on our Youtube channel. Subscribe to get practical penetration testing tutorials and demos to build your own PoCs!

G2 award badge

Pentest-Tools.com recognized as a Leader in G2’s Spring 2023 Grid® Report for Penetration Testing Software.

Discover why security and IT pros worldwide use the platform to streamline their penetration and security testing workflow.

OWASP logo

Pentest-Tools.com is a Corporate Member of OWASP (The Open Web Application Security Project). We share their mission to use, strengthen, and advocate for secure coding standards into every piece of software we develop.