Android Pentest Series: Double encrypt Mobile API (Not only AES but also RSA)

Breaking encrypted traffic with RSA and AES in Android application

Android Pentest Series: Double encrypt Mobile API (Not only AES but also RSA)

Overview

Continuing the Android pentesting series, today I will share my experience in Android pentesting with encrypted API in the Android app.

Just like my colleague wrote the first post in this series, this app was easy to bypass root and SSL pinning, but all request bodies and responses were encrypted.

In this article I will cover how I using frida to write a script that hooks into Android app and finally write a tool to automate the encryption and decryption process.

Using Frida to intercept request

After downloading the app and running it for the first time, I received a notification that root was detected.

After reviewing the source code using jadx-gui, I discovered several root detection methods.

Then I am writing a Frida script to return all this to False :

Java.perform(function() {
	//Root detected bypass
    var Rootcheck = Java.use('<className>');
	Rootcheck.b.implementation = function () {
        return false;
    };
    Rootcheck.c.implementation = function (ctx) {
        return false;
    };
    Rootcheck.e.implementation = function () {
        return false;
    };
    Rootcheck.f.implementation = function (ctx) {
        return false;
    };
    Rootcheck.g.implementation = function () {
        return false;
    };
    Rootcheck.h.implementation = function (ctx) {
        return false;
    };
  });

Root bypass is done. Next, I need to bypass SSL pinning to intercept requests. After reviewing the source code, I noticed that the application uses OkHttp3 for SSL pinning. Therefore, I use a bypass script from Frida CodeShare.

However, for requests to go through the proxy, I need to add the proxy's certificate to the system trust credentials of the device.

In my case, I add the Burp Suite certificate and MITMProxy certificate. You can refer to the method for adding a certificate here.

After that configure the Burp proxy to intercept requests, and run this script.

Easy to intercept requests but the request body and response were encrypted.

How to decrypted data with Frida

After spending a lot of time reviewing the source code, by searching some keyword like: encrypt, doFinal,cipher, etcI finally found the function responsible for encryption and decryption.

The function that encrypts the data.

The function that decrypts the data.

After finding these two functions, I can use Frida to hook into them and decrypt the encrypted data with the script below:

Java.perform(function() {
  var CryptoClass = Java.use('<class-CryptoName>');
  CryptoClass.l.overload('java.lang.String').implementation = function (str){
        console.log("[*] Plaintext input str: "+str)
        console.log("[*] Encrypt result: " + this.l(str))
        return this.l(str)
	}
  CryptoClass.m.overload("java.lang.String").implementation = function (str) {
        console.log("[*] Encrypted data: " + str);
        var result = this.m(str)
        console.log("[*] Plaintext result: " + this.m(str));
        return result; 
    };
});

Using Frida with all the script above, in the terminal all results will be displayed.

It works and prints exactly what I want. However, this only allows me to read the request, but I still can't modify it. In Burp, the request is still encrypted. Let's decrypt the request in the proxy.

Create the chained Proxies

The idea of creating a proxy will be as shown in the image above. Now, we need to analyze how the client sends requests to the server and write a proxy.

After analyzing the application's source code and examining the requests captured in Burp Suite. The application not only uses AES for encryption but also RSA.

To start a session, the application creates a handshake request with the server to exchange encryption keys.

In the handshake request, the client generates a keypair and uses the hardcode default key to perform AES encryption with the client’s public key as input, then sends it to the server. The server uses the hardcode default key to decrypt this cipher to get the client's public key and store it.

In handshake response, the server generates a key pair and a secret key, and the server uses the server secret key to perform AES encryption with the server’s public key as input. The server secret key will be RSA-encrypted using the previously received client public key. Then, the server concatenates the two ciphertexts and returns them to the client.

After receiving the handshake response, The client uses the client's private key to decrypt the RSA-encrypted data and retrieve the server's secret key. And client uses that key to decrypt the remaining part to obtain the server's public key.

After the handshake is successful, the client generates a secret key and uses it to encrypt the data with AES, then encrypts the secret key using the server's public key.

The server will use its private key to decrypt and retrieve the AES encryption key, and then use that key to decrypt the data.

On the server side, the response follows the same process as the client: it generates a secret key, uses that key to encrypt the data with AES, and finally, the secret key is encrypted using the client’s public key. After that, all the encrypted data will be sent to the client.

The part I before the delimiter is the secret key encrypted with RSA using the public key. The part II is the data encrypted with AES using the secret key.

Now that we fully understand how the client and server encrypt data but we still have a problem: All the key pairs are randomly generated at the start of a session handshake and the secret key used for AES encryption is randomly generated for each request. At the request body, we cannot decrypt part I because it is encrypted using the server’s public key.

So, how can we determine the value of the key used for AES encryption? Let's hook.

In the source code, the application calls generateKeyPair to generate KeyPair for RSA and generateKey to generate the AES secret key.

So, we use Frida to hook into the function and make it return a fixed KeyPair and AES secret key.

    var CryptoUtil_RSA = Java.use("<CryptoUtil_RSA_className>");
	var Key = Java.use("java.security.Key");
    // Hook to generateKeyPair
    CryptoUtil_RSA.generateKeyPair.overload('int', 'java.security.SecureRandom').implementation = function (i, secureRandom) {
        
        var BigInteger = Java.use("java.math.BigInteger");
        var KeyFactory = Java.use("java.security.KeyFactory");
        var RSAPublicKeySpec = Java.use("java.security.spec.RSAPublicKeySpec");
        var KeyPair = Java.use("java.security.KeyPair");
        var Base64 = Java.use("android.util.Base64");
      
        // Hardcoded Public Key
        var modulusBase64 = "";
        var exponentBase64 = "";

        var modulus = BigInteger.$new(1, Base64.decode(modulusBase64, 0));
        var exponent = BigInteger.$new(1, Base64.decode(exponentBase64, 0));

        var keyFactory = KeyFactory.getInstance("RSA");
        var publicKeySpec = RSAPublicKeySpec.$new(modulus, exponent);
        var publicKey = keyFactory.generatePublic(publicKeySpec);

        // Hardcoded Private Key
        var privateKeyBytes = [<privatekey_bytes_array>];

        var PKCS8EncodedKeySpec = Java.use("java.security.spec.PKCS8EncodedKeySpec");
        var privateKeySpec = PKCS8EncodedKeySpec.$new(privateKeyBytes);
        var privateKey = keyFactory.generatePrivate(privateKeySpec);
        // Create and return the KeyPair
        var keyPair = KeyPair.$new(publicKey, privateKey);

        return keyPair;
    };

    var CryptoUtilAES = Java.use("<CryptoUtil_AES_className>");
  // Hook to generateKey
  CryptoUtilAES.generateKey.overload("java.security.SecureRandom").implementation = function (secureRandom) {
		var AES_KEY = Java.array('byte', [<hardcoded_bytes_array>])
        
        return AES_KEY;
    };

The hardcoded key is done. Now, we need to write a proxy to automate the decryption and encryption process.

After writing proxies, I got another trouble, the data was still encrypted.

After spending time digging into the source code, I finally completed it.

MITMproxy-1

Mitmproxy-1 will be using the following script to intercept the traffic, decrypt it, and send it to the Burp

from mitmproxy import http, log
from hashlib import md5
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import base64
from app import aes_cbc_crypt,key_default,iv,decompress_gzip,aes_key_arr,iv1,DMK_value,compress_gzip
import json
import binascii

# MITM Request Hook
def request(flow: http.HTTPFlow) -> None:
    if flow.request.path == "/handshake":
        body = flow.request.text
        # Encrypt request body
        plaintext_body = base64.b64decode(aes_cbc_crypt(False,key_default, iv, base64.b64decode(body)))
        flow.request.text = decompress_gzip(plaintext_body).decode("utf-8")
    else:
        body = flow.request.text.split("|")
        data_encrypted = body[1]
        gzip_body = base64.b64decode(aes_cbc_crypt(False,aes_key_arr, iv, base64.b64decode(data_encrypted)))
        plaintext_body = decompress_gzip(gzip_body).decode("utf-8")

        json_body = json.loads(plaintext_body)
        payload = json.loads(json_body['payload'])
        cipherText = payload['cipherText']
        salt = binascii.unhexlify(payload['salt'])
        randomId = binascii.unhexlify(payload['randomId'])
        key = aes_cbc_crypt(True,DMK_value,iv1,salt+randomId)[:32]
        with open("key.txt","wb") as f:
            f.write(key)
        gzip_text = aes_cbc_crypt(False,key,iv1,binascii.unhexlify(cipherText))
        plaintext =  decompress_gzip(base64.b64decode(gzip_text)).decode()
        payload['cipherText'] = plaintext
        json_body['payload'] = payload
        
        flow.request.text = json.dumps(json_body)

def response(flow: http.HTTPFlow) -> None:
    try:
        flow.response.headers['Content-Type'] = 'application/json;charset=utf-8'   
        body = flow.response.text
        json_body = json.loads(body)
        if 'payload' in json_body:
            payload = json_body['payload']
            if 'cipherText' in payload:
                text = payload['cipherText']
                global key
                with open("key.txt","rb") as f:
                    key = f.read()
                    
                gzip_text = base64.b64encode(compress_gzip(str.encode(text)))
                cipherText = binascii.hexlify(aes_cbc_crypt(True,key,iv1,gzip_text)).decode()
                payload['cipherText'] = cipherText
                json_body['payload'] = json.dumps(payload)
        flow.response.text = json.dumps(json_body)
        
    except Exception as e:
        flow.response.text = f"Decryption failed: {str(e)}"

MITMproxy-2

Mitmproxy-2 will be using the following script to intercept the traffic from Burp, encrypt it, and send it to server

from mitmproxy import http, log
from hashlib import md5
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import base64
from app import aes_cbc_crypt,key_default,iv,decompress_gzip,compress_gzip,decrypt_rsa_message,encrypt_key,aes_key_arr,DMK_value,iv1
import json, binascii

rsa_xml = ""
# MITM Request Hook
def request(flow: http.HTTPFlow) -> None:
    body = flow.request.text
    if flow.request.path == "/handshake":
        cipher_body = base64.b64encode(aes_cbc_crypt(True,key_default,iv,base64.b64encode(compress_gzip(str.encode(body)))))
        flow.request.text = cipher_body.decode()
    else:

        json_body = json.loads(body)
        payload = json_body['payload']
        text = payload['cipherText']
        salt = binascii.unhexlify(payload['salt'])
        randomId = binascii.unhexlify(payload['randomId'])
        key = aes_cbc_crypt(True,DMK_value,iv1,salt+randomId)[:32]
        
        gzip_text = base64.b64encode(compress_gzip(str.encode(text)))
        cipherText = binascii.hexlify(aes_cbc_crypt(True,key,iv1,gzip_text)).decode()
        payload['cipherText'] = cipherText
        
        json_body['payload'] = json.dumps(payload)
        body = json.dumps(json_body)

        encrypted_key = encrypt_key(rsa_xml)
        cipher_body = base64.b64encode(aes_cbc_crypt(True,aes_key_arr,iv,base64.b64encode(compress_gzip(str.encode(body)))))
        
        flow.request.text = encrypted_key + "|" + cipher_body.decode()

def response(flow: http.HTTPFlow) -> None:
    try:
        flow.response.headers['Content-Type'] = 'application/json;charset=utf-8'   
        encrypted_body = flow.response.text.split("|")
        key_encrypted = encrypted_body[0]
        data_encrypted = base64.b64decode(encrypted_body[1])
        plain_key = base64.b64decode(decrypt_rsa_message(key_encrypted))
        data = decompress_gzip(base64.b64decode(aes_cbc_crypt(False,base64.b64decode(plain_key),iv,data_encrypted)))
        json_body = json.loads(data.decode())
        if flow.response.headers.get('Set-Cookie'):
            global rsa_xml
            rsa_xml = json_body['spkXML']
        if 'payload' in json_body:
            payload = json.loads(json_body['payload'])
            if 'cipherText' in payload:
                cipherText = payload['cipherText']
                with open("key.txt","rb") as f:
                    key = f.read()
                #key = aes_cbc_crypt(True,DMK_value,iv1,salt+randomId)[:32]
                gzip_text = aes_cbc_crypt(False,key,iv1,binascii.unhexlify(cipherText))
                plaintext =  decompress_gzip(base64.b64decode(gzip_text)).decode()
                payload['cipherText'] = plaintext
                json_body['payload'] = payload
                print(json_body)
        flow.response.text = json.dumps(json_body)
    except Exception as e:
        flow.response.text = f"Decryption failed: {str(e)}"

app.py

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
import xml.etree.ElementTree as ET
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import base64
import gzip
from io import BytesIO
from mitmproxy import http
import binascii

arr = [<fixed_bytes_arr>]
aes_key_arr= bytes(b & 0xFF for b in arr)

DMK_value = binascii.unhexlify('value')
private_key_bytes = [<fixed_key_bytes>]

key_default = base64.b64decode("<harcoded_secret_key>")

rsa_private_array = bytes(b & 0xFF for b in private_key_bytes)
private_key = RSA.import_key(rsa_private_array)

iv = bytes.fromhex("<harcoded_iv>")

i1 = [<harcoded_iv_arr>]
iv1 = bytes(b & 0xFF for b in i1)

def decompress_gzip(data: bytes) -> bytes:
    try:
        byte_stream = BytesIO(data)
        with gzip.GzipFile(fileobj=byte_stream, mode='rb') as gzip_stream:
            return gzip_stream.read()
    except IOError:
        return None

def compress_gzip(data: bytes) -> bytes:
    byte_stream = BytesIO()
    with gzip.GzipFile(fileobj=byte_stream, mode='wb',mtime=0) as gzip_stream:
        gzip_stream.write(data)
    return byte_stream.getvalue()

def aes_cbc_crypt(is_encrypt, key, iv, data):
    try:
        cipher = AES.new(key, AES.MODE_CBC, iv)
        
        if is_encrypt:
            return cipher.encrypt(pad(data, AES.block_size))  
        else:
            return unpad(cipher.decrypt(data), AES.block_size) 
    except Exception as e:
        return e
    
def parse_rsa_key(xml_key):
    root = ET.fromstring(xml_key)
    modulus_base64 = root.findtext("Modulus")
    exponent_base64 = root.findtext("Exponent")
    
    if not modulus_base64 or not exponent_base64:
        raise ValueError("Invalid RSA XML Key format")
    
    modulus = int.from_bytes(base64.b64decode(modulus_base64), 'big')
    exponent = int.from_bytes(base64.b64decode(exponent_base64), 'big')
    return modulus, exponent

def encrypt_key(xml_key):
    modulus, exponent = parse_rsa_key(xml_key)
    rsa_key = RSA.construct((modulus, exponent))
    cipher = PKCS1_v1_5.new(rsa_key)
    cipher_text = cipher.encrypt(base64.b64encode(aes_key_arr))
    return base64.b64encode(cipher_text).decode()

def decrypt_rsa_message(encrypted_message: str) -> str:
    cipher = PKCS1_v1_5.new(private_key)
    sentinel = "decryption-failed"
    decrypted_bytes = cipher.decrypt(base64.b64decode(encrypted_message),sentinel)
    return base64.b64encode(decrypted_bytes)

Result

Config Burp listening port 8088 and

Running proxies

mitmdump --listen-port 8080 --mode  upstream:http://127.0.0.1:8088 --ssl-insecure  -s .\proxy_1.py
mitmdump --listen-port 8085 -s .\proxy_2.py

And finally, all data was decrypted

Final Through

Try harder!!!