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:
-
https://gist.github.com/gboudreau/94bb0c11a6209c82418d01a59d958c93 (2024) - A detailed tutorial on how to extract TOTP tokens using the Authy desktop app (macOS, Windows, and Linux). Note: This method will not work past August 2024, which is kind of why I wrote this blog.
-
https://www.codejam.info/2021/09/authy-reversed.html (2021) - Similar to the above but they also made a nice little website project to export/import using a JS which can be found here.
-
https://randomoracle.wordpress.com/2017/02/15/extracting-otp-seeds-from-authy/ (2017) - A quite interesting technique, where they were able to extract the secret seed by debugging the web browser extension of Authy (no longer available).
-
Other useful tools and scripts: https://github.com/alexzorin/authy
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.
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.
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.
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.
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:
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 "e;
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.
- hook secure keystore of android (https://github.com/WithSecureLabs/android-keystore-audit/blob/master/frida-scripts/tracer-keystore.js) and https://labs.withsecure.com/publications/how-secure-is-your-android-keystore-authentication
✘ 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.
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:
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.
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:
227aa6ce3689bfdbdc4afd0e5376965a
(device secret seed)3
(number of OTP tokens)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.