Overview

This post should help users who want to create offline backups of Authy TOTPs secrets, using a rooted Android device, or a patched .APK file. I wrote a python script which can be used to import and export token secrets into a standardized format, including (re)generating QR codes.

I briefly cover app reversing, specifically the API endpoints for device registration. Once a device is registered, each request uses 3 OTP tokens as URL parameters that rotate every 7 seconds. These OTPs are generated by the app using a secret seed, which is unique per account or device. These were extracted by using Frida trace.

I used Burp, jadx-gui, frida and frida-trace to do most of the hard work.

If you just want to download the tool you can jump to the Download section. You can also skip to the using-a-physical-device section, as I had issues with using a virtual device.

Quick Demo

A short demo of the tool working on the latest version of Authy 25.1.1.

Recent Twilio leak

The recent breach leak of Twilio in 2024 allowed attackers to disclose millions of users’ phone numbers and account IDs. This eventually appeared on various online forums for free, maybe because the information wasn’t that valuable? not sure.

The vulnerable API endpoint in question was most likely: api.authy.com/users/{COUNTRY-CODE}-{CELL-NUMBER}/status, which also required the static api_key key, which could be easily extracted from the app.

Previous research

There has been great previous research on reversing Authy which I found helpful:

Analysis

Using a virtual device

Let’s first try the app on an emulated Android device.

Luckily the Android version of Authy is available on the Google PlayStore. Not many developers allow this whilst running as virtual device, but it’s nice it’s there. I used the Android Studio emulator installed with Google APIs. This is good as it saved me some time from using a physical device. In the end it didn’t…

You can find out how to root an emulated device here.

alt text

To use the app I needed to enter a phone number, which is an annoying requirement. I assume this is used to prevent account spam or to verify backup feature, as well as the SMS tokens. Or possibly analytics too? - not quite sure.

But before using an already registered phone number, I wanted to use this opportunity to observe the registration process with a “fresh” account, in particular the API endpoints to see if there’s anything interesting.

Network interception

I basically setup Android Studio proxy settings to point to Burp. I also have a few pass-through domains which are not intercepted, mostly default google communications and others.

certificate pinning

I quickly hit a slight road block. Burp reported there was a TLS certificate error for the hostname api.authy.com:443. This usually means there’s some sort of certificate pinning in place, which needs to be bypassed if we’re want to inspect any https content.

alt text

frida bypass

It’s relatively simple process to bypass. But that also depends on the mobile application. A few apps use complex Runtime Application Self Protection (RASP) libraries, which are typically there to slow down attackers. However, with enough time and expertise, most can be bypassed. I’ve always been impressed with the work by Romain Thomas.

After trying a few scripts I found this one that worked. I’m now able to inspect TLS traffic.

alt text

Well crap… another road block. I get a HTTP 403 Forbidden response.

Google Integrity checks

What the hell is a Google Integrity API token ?

A quote from their official developer website:

“Call the Integrity API at important moments in your app to check that user actions and requests are coming from your unmodified app binary, installed by Google Play, running on a genuine Android device. Your app’s backend server can decide what to do next to prevent abuse, unauthorized access, and attacks”.

So Authy uses this library to decide whether or not a device is safe to register an account on. If it isn’t then the backend server will prevent us from continuing with the registration or login process. This could be due to a variety of reasons e.g. emulated device, rooted device, frida running, or something else.

I should’ve really checked this earlier. But both Basic Integrity and CTS Profile match checks fail on the emulated device. This is the most likely reason Authy app rejects our requests. But of course, if you have root access, you have options.

alt text

Some options were to: 1) try to install a few Magisk modules to bypass the checks. 2) ditch the emulator and use a rooted physical device instead, also with Magisk modules. Or 3) patch the Auhy app or Google APIs manually, which is probably the most difficult and time consuming.

I tried using these Magisk modules PlayIntegrityFix and LSPosed:

adb push PlayIntegrityFix_v16.5.zip /storage/emulated/0/Download
adb push LSPosed-v1.9.2-7024-zygisk-release.zip /storage/emulated/0/Download

However, none of these modules worked. I even tried a device spoofer.

Using a physical device

Okay, after messing about with a virtual device and not getting very far, I moved onto using a real Google Pixel 3a. Annoyingly, I again ran into errors with Google’s integrity API and device verification. I still couldn’t register my device or login into the Authy app.

After a bit of research, I found some success with these two Magisk modules:

  • Shamiko version 300 from here
  • PlayIntegrity Fix from here

I could finally register the device and monitor the network traffic flow.

Device Registration

The initial POST request sent to api.authy.com includes a generated integrity_token, along with a static api_key which is also found in the app. Other device information (like IP address in the header) is also included:

POST /json/devices/access_tokens/fetch?device_app=authy&api_key=37b312a3d682b823c439522e1fd31c82&locale=en
Host: api.authy.com
User-Agent: Dalvik/2.1.0 (Linux; U; Android 10; Pixel 3a Build/QQ1A.191205.011) Mobile
X-Authy-Device-Uuid: authy::abcdfe1234567890
X-Authy-Device-App: authy
X-Authy-Request-Id: 78159512-e965-43ab-946c-17d3c172b4fb
X-Authy-Private-Ip: xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx
Content-Type: application/json; charset=UTF-8
Content-Length: 626
Connection: Keep-Alive
Accept-Encoding: gzip, deflate, br

{"device_uuid":"authy::abcdfe1234567890","integrity_token":"CtUC...5rBk","platform":"Android"}

And the response:

HTTP/2 200 OK
Date: Sun, 07 Jul 2024 20:00:59 GMT
Content-Type: application/json;charset=utf-8
Server: nginx
X-Content-Type-Options: nosniff

{"token":"eyJhbGciOiJIUzI1NiJ9.eyJ1d...LiARsFs","success":true}

If the accepted, the server responds with a JSON object that includes a new token, which will be used as the Attestation-Access-Token HTTP header for subsequent requests.

A HTTP GET request is then sent with the user phone number and device UUID:

Note: This is the API endpoint that was likely abused by attackers to find verified account phone numbers. Authy now have added the Attestation-Access-Token header to try to prevent this (of sorts).

GET /json/users/44-0-700-000-0000/status?uuid=authy%3A%3Aabcdfe1234567890&device_app=authy&api_key=37b312a3d682b823c439522e1fd31c82&locale=en HTTP/2
Host: api.authy.com
User-Agent: Dalvik/2.1.0 (Linux; U; Android 10; Pixel 3a Build/QQ1A.191205.011) Mobile
X-Authy-Device-Uuid: authy::abcdfe1234567890
X-Authy-Device-App: authy
X-Authy-Request-Id: 78159512-e965-43ab-946c-17d3c172b4fb
X-Authy-Private-Ip: xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx
Attestation-Access-Token: eyJhbGciOiJIUzI1NiJ9.eyJ1d...LiARsFs
Connection: Keep-Alive
Accept-Encoding: gzip, deflate, br

And the response:

HTTP/2 200 OK
Date: Sun, 07 Jul 2024 20:00:59 GMT
Content-Type: application/json;charset=utf-8
Server: nginx
X-Content-Type-Options: nosniff

{"force_ott":false,"primary_email_verified":false,"message":"new","success":true}

This response indicates that the mobile number supplied is not verified, but also doesn’t have a associated email with that number.

The next POST request is made with user supplied details: email address, phone number, country code and a signature (not sure where this is from) as well as, the Attestation-Access-Token HTTP header:

POST /json/users/new?device_app=authy&api_key=37b312a3d682b823c439522e1fd31c82&locale=en HTTP/2
Host: api.authy.com
User-Agent: Dalvik/2.1.0 (Linux; U; Android 10; Pixel 3a Build/QQ1A.191205.011) Mobile
X-Authy-Device-Uuid: authy::abcdfe1234567890
X-Authy-Device-App: authy
X-Authy-Request-Id: 78159512-e965-43ab-946c-17d3c172b4fb
X-Authy-Private-Ip: xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx
Attestation-Access-Token: eyJhbGciOiJIUzI1NiJ9.eyJ1d...LiARsFs
Content-Type: application/x-www-form-urlencoded
Content-Length: 110
Connection: Keep-Alive
Accept-Encoding: gzip, deflate, br

country_code=44&cellphone=0-700-000-0000&email=test%40example.com&signature=xxxxxxxxxxxxxxxx

With the response:

HTTP/2 200 OK
Date: Sun, 07 Jul 2024 20:01:52 GMT
Content-Type: application/json;charset=utf-8
Server: nginx
X-Content-Type-Options: nosniff

{"message":"Account was created.","authy_id":00000001,"registration_token":"eyJh...uk58","success":true}

Apparently, the Attestation-Access-Token HTTP header was added recently to make it harder for users to register “unsafe” devices. But more so, to avoid abuse of their APIs. I’ve mostly seen it used during account registration but other requests too.

Reviewing local app data

After registering an account, I was then able to add a few example TOTPs to see how they are stored on the app. Access to /data/data/{APP.NAME}/ folder is restricted on Android. You must have root access. This is because it’s a protected folder where each app has its own folder and permissions.

I made a copy of /data/data/com.authy.authy and started to explore its contents:

 naz@Nazs-MacBook-Air ~/Mobile/Android/Apps/Authy/com.authy.authy/shared_prefs $ ls -lha
total 328
drwxr-x--x  38 naz  staff   1.2K  7 Jul 22:44 .
drwx------  10 naz  staff   320B  7 Jul 21:54 ..
-rw-r-----   1 naz  staff   281B  7 Jul 21:00 ACCESS_TOKEN_PREFERENCES_NAME.xml
-rw-r-----   1 naz  staff   675B  7 Jul 21:54 FirebaseHeartBeatW0RFRkFVTFRd+MTo4MTIyMjcxMzI4MjE6YW5kcm9pZDphNDdkMzk0MWVkZDEzZDc4.xml
-rw-r-----   1 naz  staff   981B  7 Jul 21:50 FirebasePerfSharedPrefs.xml
-rw-r-----   1 naz  staff    65B  7 Jul 21:05 SignUpRegistrationPreferences.xml
-rw-r-----   1 naz  staff   624B  7 Jul 21:04 VERIFY.xml
-rw-r-----   1 naz  staff   239B  7 Jul 20:55 authy.storage.appSettingsV2.xml
-rw-r-----   1 naz  staff    65B  7 Jul 21:30 cacheTokenConfig.xml
-rw-r-----   1 naz  staff   730B  7 Jul 21:54 com.authy.authy.activities.TokensActivity.xml
-rw-r-----   1 naz  staff   238B  7 Jul 21:30 com.authy.authy.analytics_info_storage.xml
-rw-r-----   1 naz  staff   126B  7 Jul 21:03 com.authy.authy.config.InHouseConfig.xml
-rw-r-----   1 naz  staff   210B  7 Jul 21:30 com.authy.authy.enable_backup_reminder.xml
-rw-r-----   1 naz  staff   114B  7 Jul 20:55 com.authy.authy.models.LockManager$Lock.xml
-rw-r-----   1 naz  staff   248B  7 Jul 21:32 com.authy.authy.models.PasswordTimeStamp.xml
-rw-r-----   1 naz  staff   450B  7 Jul 21:05 com.authy.authy.models.analytics.authentication.AnalyticsTokenStorage.xml
-rw-r-----   1 naz  staff   114B  7 Jul 20:55 com.authy.authy.storage.AppSettingsStorage$AppSettings.xml
-rw-r-----   1 naz  staff   198B  7 Jul 21:35 com.authy.authy.storage.DeletionDetailsStorage.xml
-rw-r-----   1 naz  staff   114B  7 Jul 21:05 com.authy.authy.storage.DevicesStorage.xml
-rw-r-----   1 naz  staff   114B  7 Jul 21:05 com.authy.authy.storage.UserIdStorage$UserId.xml
-rw-r-----   1 naz  staff   441B  7 Jul 21:05 com.authy.authy.storage.UserInfoStorage.xml
-rw-r-----   1 naz  staff   147B  7 Jul 22:44 com.authy.authy_preferences.xml
-rw-r-----   1 naz  staff   369B  7 Jul 21:33 com.authy.storage.authenticator_password_manager.xml
-rw-r-----   1 naz  staff   265B  7 Jul 21:05 com.authy.storage.default_user_id_provider.xml
-rw-r-----   1 naz  staff   899B  7 Jul 21:39 com.authy.storage.tokens.authenticator.xml
-rw-r-----   1 naz  staff   180B  7 Jul 20:55 com.authy.storage.tokens.authy.xml
-rw-r-----   1 naz  staff    23K  7 Jul 21:05 com.authy.storage.tokens_config_v2.xml
-rw-r-----   1 naz  staff   113B  7 Jul 21:05 com.authy.storage.tokens_config_version.xml
-rw-r-----   1 naz  staff    65B  7 Jul 21:05 com.authy.storage.tokens_grid_comparator.xml
-rw-r-----   1 naz  staff   839B  7 Jul 22:44 com.google.android.gms.measurement.prefs.xml
-rw-r-----   1 naz  staff   333B  7 Jul 21:00 com.google.firebase.crashlytics.xml
-rw-r-----   1 naz  staff   137B  7 Jul 20:55 com.google.firebase.messaging.xml
-rw-r-----   1 naz  staff   154B  7 Jul 21:29 com.google.mlkit.internal.xml
-rw-r-----   1 naz  staff   3.0K  7 Jul 21:05 enpt.xml
-rw-r-----   1 naz  staff   692B  7 Jul 21:56 frc_1:812227132821:android:a47d3941edd13d78_firebase_settings.xml
-rw-r-----   1 naz  staff   434B  7 Jul 21:01 frc_1:812227132821:android:a47d3941edd13d78_fireperf_settings.xml
-rw-r-----   1 naz  staff   141B  7 Jul 21:05 prefs.passwordReminder.xml
-rw-r-----   1 naz  staff   1.4K  7 Jul 21:30 tokenConfig.xml

Searching for known keywords like my example email account and issuer.

The com.authy.storage.tokens.authenticator.xml file inside the shared_prefs folder stores ALL your TOTPs secrets on the device. It contains a decrypted and encrypted seed secret of each TOTP account. It also uses HTML encoding for specific quote symbols &quote; but this can be easily removed.

Here is an example:

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <int name="key_version" value="1026" />
    <string name="com.authy.storage.tokens.authenticator.key">[
        {"accountType":"authenticator","decryptedSecret":"wjjgnyerozgx3reoyxzukommt4","digits":6,"encryptedSecret":"vk6L0v2pw696prZJ9avJxt9hhF4GHXNB/bNx8Kwi/bU\u003d","key_derivation_iterations":100000,"logo":"Example","originalIssuer":"Example","originalName":"Example Co:[email protected]","timestamp":1720384210,"salt":"raL6WCaojHWWxZndFRlFmbshXpce2jM6","upload_state":"uploaded","hidden":false,"id":"1720384210","isNew":false,"name":"Example Co: [email protected]"},
        {"accountType":"authenticator","decryptedSecret":"AUSJD7LZ5H27TAC7NW2IJMATDMVDUPUG","digits":6,"encryptedSecret":"AKQz/j3PN3La3xwPL8ou3AHW9kMs9CpVgOU7QjaMeswxRlqYaLqkDYutUdJysXX2","key_derivation_iterations":100000,"logo":"ACME Co","originalIssuer":"ACME Co","originalName":"ACME Co:[email protected]","timestamp":1720384369,"salt":"smWX7wlzBB6xr3H1evpcd721KcZhpkvd","upload_state":"uploaded","hidden":false,"id":"1720731300","isNew":false,"name":"ACME Co: [email protected]"}]
    </string>
</map>

Within the XML is a string with the name com.authy.storage.tokens.authenticator.key. This contains an array list of JSON objects. Here is the same data just formatted better:

[{
    "accountType": "authenticator",
    "decryptedSecret": "wjjgnyerozgx3reoyxzukommt4",
    "digits": 6,
    "encryptedSecret": "vk6L0v2pw696prZJ9avJxt9hhF4GHXNB/bNx8Kwi/bU\u003d",
    "key_derivation_iterations": 100000,
    "logo": "Example",
    "originalIssuer": "Example",
    "originalName": "Example Co:[email protected]",
    "timestamp": 1720384210,
    "salt": "raL6WCaojHWWxZndFRlFmbshXpce2jM6",
    "upload_state": "uploaded",
    "hidden": false,
    "id": "1720384210",
    "isNew": false,
    "name": "Example Co: [email protected]"
},
{
    "accountType": "authenticator",
    "decryptedSecret": "AUSJD7LZ5H27TAC7NW2IJMATDMVDUPUG",
    "digits": 6,
    "encryptedSecret": "AKQz/j3PN3La3xwPL8ou3AHW9kMs9CpVgOU7QjaMeswxRlqYaLqkDYutUdJysXX2",
    "key_derivation_iterations": 100000,
    "logo": "ACME Co",
    "originalIssuer": "ACME Co",
    "originalName": "ACME Co:[email protected]",
    "timestamp": 1720384369,
    "salt": "smWX7wlzBB6xr3H1evpcd721KcZhpkvd",
    "upload_state": "uploaded",
    "hidden": false,
    "id": "1720731300",
    "isNew": false,
    "name": "ACME Co: [email protected]"
}]

The decryptedSecret value is your TOTP plaintext secret. To confirm this is actually valid, you could use something like a TOTP Token generator (only for sample accounts) to see if it matches the code on your Authy app.

For anything sensitive, I would instead use an offline script that’s open source.

Kinda like the one in this post :)

Authy TOTP secret extract tool

I’ve made a simple tool that is capable of extracting TOTP secrets from the Android Authy app. You will need a rooted device and a Frida server running. There may be other ways (without root access), like patching the Android app, but this was not explored here.

Anyway the script does the following:

  • tries to connect to a USB device via Frida module
  • spawns com.authy.authy and then attaches to the process
  • runs a Frida script to read the com.authy.storage.tokens.authenticator.xml file
  • parses the file contents and extracts relevant TOTP info
  • generates QR codes based on extracted info and prints it

Requirements

You need to install the following Python modules:

pip3 install frida pyotp qrcode

Download

It is available on my GitHub page here.

Alternative

Instead of using the script above or setting up a Frida server. One easier way (if you have a rooted device) is to install the Aegis app and then import the above XML file which they do support.

The next few sections go into details of how Authy generates OTPs for URL parameters in each of their API endpoints. As well as, some Java method tracing with frida-trace.

Runtime fun with Frida

Now that I have what I need to create backup offline, I could stop right there. However, I wanted to explore the .apk a bit, and also figure out how the OTPs tokens are generated. These are supplied in various HTTP requests, after account registration.

Senstive Java operations

WithSecureLabs have a great collection of Frida scripts on their GitHub page. I used several such as tracer-keystore.js, tracer-secretkeyfactory.js, and others.

 ✘ naz@Nazs-MacBook-Air ~/Mobile/Android/Frida $ frida -U -l tracer-keystore.js -f com.authy.authy
     ____
    / _  |   Frida 16.1.4 - A world-class dynamic instrumentation toolkit
   | (_| |
    > _  |   Commands:
   /_/ |_|       help      -> Displays the help system
   . . . .       object?   -> Display information about 'object'
   . . . .       exit/quit -> Exit
   . . . .
   . . . .   More info at https://frida.re/docs/home/
   . . . .
   . . . .   Connected to Pixel 3a (id=9C6AY1MMQX)
Spawning `com.authy.authy`...
KeyStore hooks loaded!
Spawned `com.authy.authy`. Resuming main thread!
[Pixel 3a::com.authy.authy ]-> [Keystore.getInstance()]: type: BKS
[Keystore.load(InputStream, char[])]: keystoreType: BKS, password: '(null)', inputSteam: null
[Keystore.getInstance()]: type: AndroidKeyStore
[Keystore.load(LoadStoreParameter)]: keystoreType: AndroidKeyStore, param: null
[Keystore.getInstance()]: type: AndroidKeyStore
[Keystore.load(LoadStoreParameter)]: keystoreType: AndroidKeyStore, param: null
[Keystore.getInstance()]: type: BKS
[Keystore.load(LoadStoreParameter)]: keystoreType: BKS, param: null
[Keystore.getInstance()]: type: BKS
[Keystore.load(InputStream, char[])]: keystoreType: BKS, password: 'changeit', inputSteam: android.content.res.AssetManager$AssetInputStream@ec77830
[Keystore.getInstance()]: type: BKS
[Keystore.load(InputStream, char[])]: keystoreType: BKS, password: '(null)', inputSteam: null
[Keystore.getInstance()]: type: BKS
[Keystore.load(LoadStoreParameter)]: keystoreType: BKS, param: null
[Keystore.getInstance()]: type: AndroidKeyStore
[Keystore.load(LoadStoreParameter)]: keystoreType: AndroidKeyStore, param: null
[Keystore.getKey()]: alias: sig1810030805, password: '(null)'
[Keystore.getKey()]: alias: enc1810030805, password: '(null)'
[Keystore.getInstance()]: type: AndroidKeyStore
[Keystore.load(LoadStoreParameter)]: keystoreType: AndroidKeyStore, param: null
[Keystore.getKey()]: alias: enpt, password: '(null)'
[Keystore.getKey()]: alias: enpt, password: '(null)'
[Keystore.getKey()]: alias: enpt, password: '(null)'
[Keystore.getKey()]: alias: enpt, password: '(null)'
[Keystore.getKey()]: alias: enpt, password: '(null)'
[Keystore.getKey()]: alias: enpt, password: '(null)'
[Keystore.getKey()]: alias: enpt, password: '(null)'
[Pixel 3a::com.authy.authy ]->
[Pixel 3a::com.authy.authy ]-> exit

Nothing too interesting, let’s move on to the tracer-secretkeyfactory.js script.

 naz@Nazs-MacBook-Air ~/Mobile/Android/Frida $ frida -U -l tracer-secretkeyfactory.js -f com.authy.authy
     ____
    / _  |   Frida 16.1.4 - A world-class dynamic instrumentation toolkit
   | (_| |
    > _  |   Commands:
   /_/ |_|       help      -> Displays the help system
   . . . .       object?   -> Display information about 'object'
   . . . .       exit/quit -> Exit
   . . . .
   . . . .   More info at https://frida.re/docs/home/
   . . . .
   . . . .   Connected to Pixel 3a (id=9C6AY1MMQX)
Spawning `com.authy.authy`...
SecretKeyFactory hooks loaded!
Spawned `com.authy.authy`. Resuming main thread!
[Pixel 3a::com.authy.authy ]-> [PBEKeySpec.PBEKeySpec3()]: pass: %sD=#4utHy.>{dwp&@ iter: 100 keyLength: 256
salt:

  Offset  00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00000000  C2 48 C6 29 AF 1F E0 A8 C4 6B 95 66 80 64 C1 D2  .H.).....k.f.d..
00000010  95 2A 9E 91 D2 07 BC 0C C3 C5 D5 84 C2 F7 55 3A  .*............U:

[PBEKeySpec.PBEKeySpec3()]: pass: %sD=#4utHy.>{dwp&@ iter: 100 keyLength: 256
...

The output has been truncated to save space. But a pass field with the value of %sD=#4utHy.>{dwp&@ is present. If you look at it closer, you’ll notice 4utHy almost resembles the string Authy. I’m not quite sure how it’s used or for what purpose.

Tracing Java Classes

I used the jadx-gui Java decompiler, to inspect the app, luckily it wasn’t too heavily obfuscated. I could find classes relating to functions or classes that have specific keywords. Keywords from intercepted traffic.

alt text

alt text

Other keywords like opt1, secret, backup_key and others can be found. These were also in the HTTP requests intercepted eariler. The Java package called com.authy.authy.api.requestInterceptors is responsible for constructing requests that are sent to backend servers. The class CompleteParamsRequestInterceptor makes up most of the package.

Here’s what the code looks like within Jadx-gui:

alt text

I noticed that the com.authy.authy.models.MasterApp class made references to com.authy.authy.models.AuthyApp, which contained functions such as getOtp(), isConfigured(), validateAndLock() and others. I was mainly curious in the getOtp() function because I wanted to know how it gets generated.

static api_key value

The api_key keyword can be found in a class called com.authy.authy.api.AuthyAPI. It appears to be statically set to 37b312a3d682b823c439522e1fd31c82. This could change between app versions. Although, they have been using this since older versions.

alt text

frida-trace java classes

With some key class names, I then used frida-trace with the -j option to search for any usage of the com.authy.authy.models.AuthyApp class during app runtime. The -f parameter is what starts or spawns the app.

 naz@Nazs-MacBook-Air ~/Mobile/Android/Frida/lol2 $ frida-trace -U -j 'com.authy.authy.models.AuthyApp!*' -f com.authy.authy
Instrumenting...
AuthyApp.$init: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol/__handlers__/com.authy.authy.models.AuthyApp/_init.js"
AuthyApp.addExtraDataBeforeSave: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol/__handlers__/com.authy.authy.models.AuthyApp/addExtraDataBeforeSave.js"
AuthyApp.decrypt: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol/__handlers__/com.authy.authy.models.AuthyApp/decrypt.js"
AuthyApp.getConfigId: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol/__handlers__/com.authy.authy.models.AuthyApp/getConfigId.js"
AuthyApp.getInternalId: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol/__handlers__/com.authy.authy.models.AuthyApp/getInternalId.js"
AuthyApp.getLogoImage: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol/__handlers__/com.authy.authy.models.AuthyApp/getLogoImage.js"
AuthyApp.getMenuImage: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol/__handlers__/com.authy.authy.models.AuthyApp/getMenuImage.js"
AuthyApp.getOtp: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol/__handlers__/com.authy.authy.models.AuthyApp/getOtp.js"
AuthyApp.getSecretKey: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol/__handlers__/com.authy.authy.models.AuthyApp/getSecretKey.js"
AuthyApp.getTokenIdLabel: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol/__handlers__/com.authy.authy.models.AuthyApp/getTokenIdLabel.js"
AuthyApp.getTokenLabel: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol/__handlers__/com.authy.authy.models.AuthyApp/getTokenLabel.js"
AuthyApp.getUniqueId: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol/__handlers__/com.authy.authy.models.AuthyApp/getUniqueId.js"
AuthyApp.isConfigured: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol/__handlers__/com.authy.authy.models.AuthyApp/isConfigured.js"
AuthyApp.setSecretKey: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol/__handlers__/com.authy.authy.models.AuthyApp/setSecretKey.js"
AuthyApp.toBluetoothInfo: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol/__handlers__/com.authy.authy.models.AuthyApp/toBluetoothInfo.js"
AuthyApp.updateConfig: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol/__handlers__/com.authy.authy.models.AuthyApp/updateConfig.js"
AuthyApp.validateAndLock: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol/__handlers__/com.authy.authy.models.AuthyApp/validateAndLock.js"
Started tracing 17 functions. Press Ctrl+C to stop.
           /* TID 0x1763 */
   578 ms  AuthyApp.$init()
           /* TID 0x174a */
   811 ms  AuthyApp.isConfigured()
   823 ms  <= true
   844 ms  AuthyApp.getSecretKey()
   846 ms  <= "227aa6ce3689bfdbdc4afd0e5376965a"
           /* TID 0x17e3 */
  1020 ms  AuthyApp.getSecretKey()
  1025 ms  <= "227aa6ce3689bfdbdc4afd0e5376965a"
           /* TID 0x183e */
  1420 ms  AuthyApp.getSecretKey()
  1423 ms  <= "227aa6ce3689bfdbdc4afd0e5376965a"
           /* TID 0x1721 */
  1520 ms  AuthyApp.getSecretKey()
  1524 ms  <= "227aa6ce3689bfdbdc4afd0e5376965a"
  1642 ms  AuthyApp.getSecretKey()
  1644 ms  <= "227aa6ce3689bfdbdc4afd0e5376965a"
  1764 ms  AuthyApp.getSecretKey()
  1767 ms  <= "227aa6ce3689bfdbdc4afd0e5376965a"
           /* TID 0x174a */
  1772 ms  AuthyApp.isConfigured()
  1777 ms  <= true

The AuthyApp.getSecretKey() method is called multiple times, when the app starts. The value returned is 227aa6ce3689bfdbdc4afd0e5376965a. I later realised this is the secret seed which is used for generating the OTPs tokens.

Doing the same thing but on a different class com.authy.authy.models.otp.OtpGenerator shows that the above seed is used as input as the first paramater for a function called OtpGenerator.generateConsecutiveOTPS.

Here’s the calls:

 naz@Nazs-MacBook-Air ~/Mobile/Android/Frida/lol2 $ frida-trace -U -j 'com.authy.authy.models.otp.OtpGenerator!*' -f com.authy.authy
Instrumenting...
OtpGenerator.$init: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol2/__handlers__/com.authy.authy.models.otp.OtpGenerator/_init.js"
OtpGenerator.getInstance: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol2/__handlers__/com.authy.authy.models.otp.OtpGenerator/getInstance.js"
OtpGenerator.generateConsecutiveOTPS: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol2/__handlers__/com.authy.authy.models.otp.OtpGenerator/generateConsecutiveOTPS.js"
OtpGenerator.generateOTP: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol2/__handlers__/com.authy.authy.models.otp.OtpGenerator/generateOTP.js"
OtpGenerator.getMovingFactor: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol2/__handlers__/com.authy.authy.models.otp.OtpGenerator/getMovingFactor.js"
OtpGenerator.hashToHexString: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol2/__handlers__/com.authy.authy.models.otp.OtpGenerator/hashToHexString.js"
OtpGenerator.hmac_sha1: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol2/__handlers__/com.authy.authy.models.otp.OtpGenerator/hmac_sha1.js"
Started tracing 7 functions. Press Ctrl+C to stop.
           /* TID 0x5004 */
   860 ms  OtpGenerator.generateConsecutiveOTPS("227aa6ce3689bfdbdc4afd0e5376965a", 3, 7)
   860 ms     | OtpGenerator.generateOTP([56,99,50,100,56,101,49,55,49,57,101,100,100,50,57,49,54,48,49,55,102,98,101,99,56,48,49,55,57,55,49,55], [49,55,50,48,55,50,54,52,49], 7)
   862 ms     |    | OtpGenerator.hmac_sha1([56,99,50,100,56,101,49,55,49,57,101,100,100,50,57,49,54,48,49,55,102,98,101,99,56,48,49,55,57,55,49,55], [49,55,50,48,55,50,54,52,49])
   866 ms     |    | <= [-64,-19,-98,-36,56,18,-28,-119,-51,93,10,-43,32,-95,-23,120,78,-10,125,-11]
   867 ms     |    | OtpGenerator.hashToHexString([-64,-19,-98,-36,56,18,-28,-119,-51,93,10,-43,32,-95,-23,120,78,-10,125,-11])
   867 ms     |    | <= "c0ed9edc3812e489cd5d0ad520a1e9784ef67df5"
   867 ms     | <= "9313298"
   867 ms     | OtpGenerator.generateOTP([56,99,50,100,56,101,49,55,49,57,101,100,100,50,57,49,54,48,49,55,102,98,101,99,56,48,49,55,57,55,49,55], [49,55,50,48,55,50,54,52,50], 7)
   868 ms     |    | OtpGenerator.hmac_sha1([56,99,50,100,56,101,49,55,49,57,101,100,100,50,57,49,54,48,49,55,102,98,101,99,56,48,49,55,57,55,49,55], [49,55,50,48,55,50,54,52,50])
   869 ms     |    | <= [57,61,104,-108,31,-110,111,-29,69,80,-32,17,39,-50,-24,-40,70,82,62,-2]
   869 ms     |    | OtpGenerator.hashToHexString([57,61,104,-108,31,-110,111,-29,69,80,-32,17,39,-50,-24,-40,70,82,62,-2])
   870 ms     |    | <= "393d68941f926fe34550e01127cee8d846523efe"
   870 ms     | <= "8310670"
   870 ms     | OtpGenerator.generateOTP([56,99,50,100,56,101,49,55,49,57,101,100,100,50,57,49,54,48,49,55,102,98,101,99,56,48,49,55,57,55,49,55], [49,55,50,48,55,50,54,52,51], 7)
   872 ms     |    | OtpGenerator.hmac_sha1([56,99,50,100,56,101,49,55,49,57,101,100,100,50,57,49,54,48,49,55,102,98,101,99,56,48,49,55,57,55,49,55], [49,55,50,48,55,50,54,52,51])
   873 ms     |    | <= [47,39,-65,43,-21,-27,-114,-30,-7,-114,-79,-93,35,119,-64,-46,121,113,-82,34]
   874 ms     |    | OtpGenerator.hashToHexString([47,39,-65,43,-21,-27,-114,-30,-7,-114,-79,-93,35,119,-64,-46,121,113,-82,34])
   875 ms     |    | <= "2f27bf2bebe58ee2f98eb1a32377c0d27971ae22"
   875 ms     | <= "1677502"
   878 ms  <= "<instance: java.util.ArrayList>"

As shown above, three calls to OtpGenerator.generateOTP are made which return the following OTP tokens: 9313298, 8310670, 1677502. The caller of this function OtpGenerator.generateConsecutiveOTPS, can also be seen taking three input parameters which are:

  1. 227aa6ce3689bfdbdc4afd0e5376965a (device secret seed)
  2. 3 (number of OTP tokens)
  3. 7 (number of OTP digits)

The reason why all three OTP tokens are different is because the app also adds a few milliseconds to the initial time retireved from the system time. Back in 2021, a researcher reverse engineered the OTP generation of Authy, which is available here.

A brief look at the com.authy.authy.models.movingFactor.MovingFactor class:

 naz@Nazs-MacBook-Air ~/Mobile/Android/Frida/lol2 $ frida-trace -U -j 'com.authy.authy.models.movingFactor.MovingFactor*!*' -f com.authy.authy
Instrumenting...
MovingFactor$Corrector.$init: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol2/__handlers__/com.authy.authy.models.movingFactor.MovingFactor_Corrector/_init.js"
MovingFactor$Corrector.getLocalTime: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol2/__handlers__/com.authy.authy.models.movingFactor.MovingFactor_Corrector/getLocalTime.js"
MovingFactor$Corrector.getTimeInServerUnits: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol2/__handlers__/com.authy.authy.models.movingFactor.MovingFactor_Corrector/getTimeInServerUnits.js"
MovingFactor$Corrector.getTimeInServerUnits$default: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol2/__handlers__/com.authy.authy.models.movingFactor.MovingFactor_Corrector/getTimeInServerUnits_default.js"
MovingFactor$Corrector.getCurrentTime: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol2/__handlers__/com.authy.authy.models.movingFactor.MovingFactor_Corrector/getCurrentTime.js"
MovingFactor$Corrector.getCurrentTimeInServerUnits: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol2/__handlers__/com.authy.authy.models.movingFactor.MovingFactor_Corrector/getCurrentTimeInServerUnits.js"
MovingFactor$Corrector.getMovingFactor: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol2/__handlers__/com.authy.authy.models.movingFactor.MovingFactor_Corrector/getMovingFactor.js"
MovingFactor$Corrector.isTimeCorrectionSignificant: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol2/__handlers__/com.authy.authy.models.movingFactor.MovingFactor_Corrector/isTimeCorrectionSignificant.js"
MovingFactor$Corrector.updateMovingFactorCorrection: Auto-generated handler at "/Users/naz/Mobile/Android/Frida/lol2/__handlers__/com.authy.authy.models.movingFactor.MovingFactor_Corrector/updateMovingFactorCorrection.js"
MovingFactor.$init: Loaded handler at "/Users/naz/Mobile/Android/Frida/lol2/__handlers__/com.authy.authy.models.movingFactor.MovingFactor/_init.js"
MovingFactor.access$getMovingFactorCorrection$cp: Loaded handler at "/Users/naz/Mobile/Android/Frida/lol2/__handlers__/com.authy.authy.models.movingFactor.MovingFactor/access_getMovingFactorCorrection_cp.js"
MovingFactor.access$setMovingFactorCorrection$cp: Loaded handler at "/Users/naz/Mobile/Android/Frida/lol2/__handlers__/com.authy.authy.models.movingFactor.MovingFactor/access_setMovingFactorCorrection_cp.js"
Started tracing 12 functions. Press Ctrl+C to stop.
           /* TID 0x60c7 */
   426 ms  MovingFactor$Corrector.$init(null)
           /* TID 0x6118 */
  1193 ms  MovingFactor$Corrector.updateMovingFactorCorrection("1720728191000")
  1193 ms  MovingFactor$Corrector.getMovingFactor()
  1193 ms  <= "-754"
  1283 ms  MovingFactor$Corrector.updateMovingFactorCorrection("1720728191000")
  1284 ms  MovingFactor$Corrector.getMovingFactor()
  1284 ms  <= "-844"
           /* TID 0x60b1 */
 65472 ms  MovingFactor$Corrector.getCurrentTime()
 65472 ms  <= "1720728255189"
 65475 ms  MovingFactor$Corrector.getCurrentTime("<instance: java.util.concurrent.TimeUnit, $className: java.util.concurrent.TimeUnit$4>")
 65477 ms  <= "1720728255"
 81078 ms  MovingFactor$Corrector.getCurrentTime("<instance: java.util.concurrent.TimeUnit, $className: java.util.concurrent.TimeUnit$4>")
 81079 ms  <= "1720728270"
 81083 ms  MovingFactor$Corrector.getCurrentTime()
 81083 ms  <= "1720728270800"
112055 ms  MovingFactor$Corrector.getCurrentTime("<instance: java.util.concurrent.TimeUnit, $className: java.util.concurrent.TimeUnit$4>")
112056 ms  <= "1720728301"
112058 ms  MovingFactor$Corrector.getCurrentTime()
112059 ms  <= "1720728301775"

Dumping RSA private key

Dumps device/account RSA private key and secret seed, amoung other calls:

frida-trace -U -f com.authy.authy -j 'com.authy.authy.util.CryptoHelper*!*/i'

Changing device time

I thought I would try messing about with the device time so I could see how the app would handle it. For a brief moment the TOTP tokens were the same every 30 seconds. But after a bit the app was smart enough to check time syncs with a backend server and regenerate token from that time instead.

Here’s a Frida script I found that changes the device time to Thu Dec 31 2020 16:00:00:

Java.perform(() => {
// This function will be called every time System.currentTimeMillis() is called
function hook() {
  // Return the Unix timestamp for "Thu Dec 31 2020 16:00:00"
 return 1609459200000;
}

// Create a Frida hook on the System.currentTimeMillis() method
var System = Java.use('java.lang.System');
        System.currentTimeMillis.implementation = hook;
});

You’ll notice your Authy TOTP tokens remain the same, at least for a bit.

Summary

Authy makes it annoying to create offline backups, forcing users to only allow uploading to their servers. However, with a bit of trial and error we can export our 2FA secrets with the help of Frida and a rooted Android device.

But before you delete your Authy account.

There have been some reports from users that when you delete your Authy account. This will invalidate all 2FA token that use it as a backend. This includes Twitch, SendGrid, xxxx, and others. When you delete your account, there’s a 1 month delay. Read source.

This would make sense, however, it’s not clearly documented to users.