TL;DR: The AlwaysTrustUserCerts module now supports Android 7 until Android 16 Beta. If you want to learn more about Mainline, Conscrypt and how everything works together, keep reading!
To properly test the backend of any mobile application, we need to intercept (and modify) the API traffic. We could use Swagger or Postman files if they are available, but it’s a lot easier to intercept real traffic so we don’t have to worry about providing correct values, sequencing, etc.
Sometimes intercepting traffic is very straightforward: You configure a device proxy, install your proxy certificate on the device and you’re good to go. This was even the default behavior until Android stopped trusting user certificates by default. On recent versions of Android, this will only work if the network security config has been modified to include user certificates, or if the user certificate has been moved into the system certificate repository.
Android 14 (A14) made interception a bit more difficult by moving all root certificates to a Mainline module, as I’ll explain below. Recently though, one of our devices was showing the same behavior, even on A13. While I was surprised initially, it actually makes a lot of sense. Let’s dive in!
The Conscrypt module, which was introduced in Android 9, is used by the Android OS to verify the TLS certificates of HTTPS connections. It contains Conscrypt itself in the form of a Java security provider, and the BoringSSL library, Google’s Fork of OpenSSL. Conscrypt is still the default security provider on Android 15 (A15).
In Android 10, Google introduced Mainline, which is a way for Google to update certain parts of the Android OS without requiring an over-the-air (OTA) update. These updates are installed via the Google Play Services app and require an onboarded Google Play app. Since these Mainline updates are completely separated from system updates, even devices that no longer receive official OS updates can still receive security updates for selected components. Since the introduction in Android 10, many modules have been merged into Mainline, currently bringing the total to 33 modules for A15.
As a result, devices typically have two update levels:
Internally, Mainline modules are located in the /apex/
folder and can be viewed with root permissions. On a fresh Android 13 (A13) installation (UP1A.231005.007
), the /apex/
folder might look as follows:
$ ls -lh /apex
total 256K
-rw-r--r-- 1 root system 11K 2025-05-12 17:20 apex-info-list.xml
drwxr-xr-x 7 system system 4.0K 1970-01-01 01:00 com.android.adbd
drwxr-xr-x 7 system system 4.0K 1970-01-01 01:00 com.android.adbd@331314022
drwxr-xr-x 8 system system 4.0K 1970-01-01 01:00 com.android.adservices
drwxr-xr-x 8 system system 4.0K 1970-01-01 01:00 com.android.adservices@331418080
drwxr-xr-x 6 system system 4.0K 1970-01-01 01:00 com.android.apex.cts.shim
drwxr-xr-x 6 system system 4.0K 1970-01-01 01:00 com.android.apex.cts.shim@1
drwxr-xr-x 6 system system 4.0K 1970-01-01 01:00 com.android.appsearch
drwxr-xr-x 6 system system 4.0K 1970-01-01 01:00 com.android.appsearch@331311000
drwxr-xr-x 8 system system 4.0K 1970-01-01 01:00 com.android.art
drwxr-xr-x 8 system system 4.0K 1970-01-01 01:00 com.android.art@331413030
drwxr-xr-x 7 system system 4.0K 1970-01-01 01:00 com.android.btservices
drwxr-xr-x 7 system system 4.0K 1970-01-01 01:00 com.android.btservices@331716000
drwxr-xr-x 5 system system 4.0K 1970-01-01 01:00 com.android.cellbroadcast
drwxr-xr-x 5 system system 4.0K 1970-01-01 01:00 com.android.cellbroadcast@331411000
drwxr-xr-x 8 system system 4.0K 1970-01-01 01:00 com.android.compos
drwxr-xr-x 8 system system 4.0K 1970-01-01 01:00 com.android.compos@1
drwxr-xr-x 8 system system 4.0K 1970-01-01 01:00 com.android.conscrypt
drwxr-xr-x 8 system system 4.0K 1970-01-01 01:00 com.android.conscrypt@331411000
Bash
The apex-info-list.xml
contains an overview of the installed modules. For example, for com.android.conscrypt we have the following element:
<?xml version="1.0" encoding="utf-8"?>
<apex-info-list>
...
<apex-info
moduleName="com.android.conscrypt"
modulePath="/data/apex/decompressed/[email protected]"
preinstalledModulePath="/system/apex/com.google.android.conscrypt.apex"
versionCode="331411000"
versionName=""
isFactory="true"
isActive="true"
lastUpdateMillis="1747063014"
provideSharedApexLibs="false"
/>
...
</apex-info-list>
XML
The Conscript module itself contains some metadata, the BoringSSL library, and the Conscrypt security provider:
$ ls -lah /apex/com.android.conscrypt
total 48K
drwxr-xr-x 8 system system 4.0K 1970-01-01 01:00 .
drwxr-xr-x 64 root root 1.3K 2025-05-12 11:37 ..
-rw-r--r-- 1 system system 61 1970-01-01 01:00 apex_manifest.json
-rw-r--r-- 1 system system 103 1970-01-01 01:00 apex_manifest.pb
drwxr-xr-x 2 root shell 4.0K 1970-01-01 01:00 bin
drwxr-xr-x 3 root shell 4.0K 1970-01-01 01:00 etc
drwxr-xr-x 2 root shell 4.0K 1970-01-01 01:00 javalib
drwxr-xr-x 2 root shell 4.0K 1970-01-01 01:00 lib
drwxr-xr-x 2 root shell 4.0K 1970-01-01 01:00 lib64
drwx------ 2 root root 16K 1970-01-01 01:00 lost+found
Bash
When Android validates a TLS certificate chain, it does so using a collection of root certificate authorities. All versions of Android have the /system/etc/security/cacerts
folder:
$ ls /system/etc/security/cacerts
00673b5b.0 35105088.0 5e4e69e7.0 88950faa.0 b0f3e76e.0 d16a5865.0
04f60c28.0 399e7759.0 5f47b495.0 89c02a45.0 b3fb433b.0 d18e9066.0
0d69c7e1.0 3a3b02ce.0 60afe812.0 8d6437c3.0 b74d2bd5.0 d41b5e2a.0
10531352.0 3ad48a91.0 6187b673.0 91739615.0 b7db1890.0 d4c339cb.0
111e6273.0 3c58f906.0 63a2c897.0 9282e51c.0 b872f2b4.0 d59297b8.0
12d55845.0 3c6676aa.0 67495436.0 9339512a.0 b936d1c6.0 d7746a63.0
1dcd6f4c.0 3c860d51.0 69105f4f.0 9479c8c3.0 bc3f2570.0 da7377f6.0
1df5a75f.0 3c899c73.0 6b03dec0.0 9576d26b.0 bd43e1dd.0 dbc54cab.0
1e1eab7c.0 3c9a4d3b.0 75680d2e.0 95aff9e3.0 bdacca6f.0 dbff3a01.0
1e8e7201.0 3d441de8.0 76579174.0 9685a493.0 bf64f35b.0 dc99f41e.0
1eb37bdf.0 3e7271e8.0 7892ad52.0 9772ca32.0 c2c1704e.0 dfc0fe80.0
1f58a078.0 40dc992e.0 7999be0d.0 985c1f52.0 c491639e.0 e442e424.0
219d9499.0 455f1b52.0 7a7c655d.0 9d6523ce.0 c51c224c.0 e48193cf.0
23f4c490.0 48a195d8.0 7a819ef2.0 9f533518.0 c559d742.0 e775ed2d.0
27af790d.0 4be590e0.0 7c302982.0 a2c66da8.0 c7e2a638.0 e8651083.0
2add47b6.0 5046c355.0 7d453d8f.0 a3896b44.0 c907e29b.0 ed39abd0.0
2d9dafe4.0 524d9b43.0 81b9768f.0 a7605362.0 c90bc37d.0 f013ecaf.0
2fa87019.0 52b525c7.0 82223c44.0 a7d2cf64.0 cb156124.0 f0cd152c.0
302904dd.0 583d0756.0 85cde254.0 a81e292b.0 cb1c3204.0 f459871d.0
304d27c3.0 5a250ea7.0 86212b19.0 ab5346f4.0 ccc52f49.0 facacbc6.0
31188b5e.0 5a3f0ff8.0 869fbf79.0 ab59055e.0 cf701eeb.0 fb5fa911.0
33ee480d.0 5acf816d.0 87753b0d.0 aeb67534.0 d06393bb.0 fd08c599.0
343eb6cb.0 5cf9d536.0 882de061.0 b0ed035a.0 d0cddf45.0 fde84897.0
Bash
However, with A14, Google started including a cacerts folder inside of the /apex/com.android.conscrypt/
package, too:
$ ls -l /apex/com.android.conscrypt/
-rw-r--r-- 1 system system 103 1970-01-01 01:00 apex_manifest.pb
drwxr-xr-x 2 root shell 4096 1970-01-01 01:00 bin
drwxr-xr-x 2 root shell 4096 1970-01-01 01:00 cacerts
drwxr-xr-x 3 root shell 4096 1970-01-01 01:00 etc
drwxr-xr-x 2 root shell 4096 1970-01-01 01:00 javalib
drwxr-xr-x 2 root shell 4096 1970-01-01 01:00 lib64
drwx------ 2 root root 16384 1970-01-01 01:00 lost+found
Bash
The original certificates on /system
are still available, but they are only used as a fallback; if the cacerts folder is available via conscrypt, it will get priority over the ones stored at /system/etc/security/cacerts
. The code below, taken from /apex/com.android.conscrypt/javalib/conscrypt.jar
, shows this behavior. Note that this snippet also hints at a potential alternative way to disable apex certificate management via the system.certs.enabled
property:
static {
String ANDROID_ROOT = System.getenv("ANDROID_ROOT");
String ANDROID_DATA = System.getenv("ANDROID_DATA");
File updatableDir = new File("/apex/com.android.conscrypt/cacerts");
if (System.getProperty("system.certs.enabled") != null && System.getProperty("system.certs.enabled").equals("true")) {
defaultCaCertsSystemDir = new File(ANDROID_ROOT + "/etc/security/cacerts");
} else if (updatableDir.exists() && updatableDir.list().length != 0) {
defaultCaCertsSystemDir = updatableDir;
} else {
defaultCaCertsSystemDir = new File(ANDROID_ROOT + "/etc/security/cacerts");
}
TrustedCertificateStore.setDefaultUserDirectory(new File(ANDROID_DATA + "/misc/keychain"));
}
Java
So we could enable this system property and be done with it, but there are actually a few issues:
The AlwaysTrustUserCerts module currently only copies user certificates into the /system/
directory, which was enough until now. By adding the certificates before Zygote is initialized, the certificates automatically propagate to all apps when they are forked from Zygote. To make the module work with A14, we want to still copy the user certs into /system/
, but also make sure that they are added to the /apex/
directory.
Unfortunately, adding certificates to the /apex/
folder is more complicated, as documented by Tim Perry on the httptoolkit blog: Any changes we make here will not automatically propagate to new applications, due to the way each app’s /apex
folder is mounted.
As suggested in the httptoolkit blogpost, there are a few potential solutions, some of which require iteratively going into every process and updating the mounts to pick up our changes. To make sure the update covers both /system
and /apex
certificates, the module now does the following:
$MODULE/system/etc/security/cacerts
/system/etc/security/cacerts
folder/system/etc/security/cacerts
onto /apex/com.android.conscrypt/cacerts
in zygote and all its childrenStep 3 is required because even though zygote will see the newly mounted certificates, the mount will not propagate to its children since the /apex/
is specifically mounted with PRIVATE propagation.
It took a bit of work, but AlwaysTrustUserCerts now allows you to fully intercept HTTPS traffic on A14 🥳.
A14 comes with Mainline-updatable root-CAs out of the box. But …the whole idea of Mainline is to bring important security updates to devices without requiring a full OTA update. Managing root certificate authorities definitely is a security-critical service, and since Conscrypt is part of Mainline, these updates can be made available to pre-A14 devices, too!
A commit from December 2022 mentions the inclusion of CA certificates in apex:
Add conscrypt updatable certificates.This cl adds the new blueprint files required for certificate loading, and an additional ca_certificates_apex build rule used to create the prebuild modules for loading certificates. While we are currently have to out all certificates within Conscrypt's apex build rules, we intend to later avoid that step.
But which devices will get this specific update? This question is answered a few commits later, when the minSDK is set to 30 (A11):
Merge "Add minSdkVersion="30" to Conscrypt APEX" into main
It’s currently still at this value, so let’s do a quick test and flash A11.0.0 (RP1A.200720.009, Sep 2020
) to my Pixel 3a device. After the initial installation, we have version 300900703
which does not have a cacerts
folder yet:
$ ls -lh com.android.conscrypt@300900703/
total 22K
-rw-r--r-- 1 system system 62 1970-01-01 01:00 apex_manifest.json
-rw-r--r-- 1 system system 85 1970-01-01 01:00 apex_manifest.pb
drwxr-xr-x 2 root shell 4.0K 1970-01-01 01:00 bin
drwxr-xr-x 2 root shell 4.0K 1970-01-01 01:00 etc
drwxr-xr-x 2 root shell 4.0K 1970-01-01 01:00 javalib
drwxr-xr-x 2 root shell 4.0K 1970-01-01 01:00 lib
drwxr-xr-x 2 root shell 4.0K 1970-01-01 01:00 lib64
drwx------ 2 root root 16K 1970-01-01 01:00 lost+found
Bash
Unfortunately, try as a I might, I couldn’t trigger a GPSU. After doing some research, it seems that multiple users have this issue, and the suggested fix is to update to A12. So let’s give that a try and install SP1A.210812.015
, the first available A12 version for Pixel 3a:
# SP1A.210812.015 - pre update
sargo:/apex/com.android.conscrypt@310727000 # ls -l
total 44
-rw-r--r-- 1 system system 62 1970-01-01 01:00 apex_manifest.json
-rw-r--r-- 1 system system 103 1970-01-01 01:00 apex_manifest.pb
drwxr-xr-x 2 root shell 4096 1970-01-01 01:00 bin
drwxr-xr-x 3 root shell 4096 1970-01-01 01:00 etc
drwxr-xr-x 2 root shell 4096 1970-01-01 01:00 javalib
drwxr-xr-x 2 root shell 4096 1970-01-01 01:00 lib
drwxr-xr-x 2 root shell 4096 1970-01-01 01:00 lib64
drwx------ 2 root root 16384 1970-01-01 01:00 lost+found
Bash
Luckily this time we do get an update after refreshing the update window a few times (April 1st 2025) and the cacerts folder is now available:
# SP1A.210812.015 - post update (April 1st 2025 update)
sargo:/apex/com.android.conscrypt@351412000 # ls -lah
total 48K
drwxr-xr-x 9 system system 4.0K 1970-01-01 01:00 .
drwxr-xr-x 55 root root 1.1K 2025-05-13 09:59 ..
-rw-r--r-- 1 system system 103 1970-01-01 01:00 apex_manifest.pb
drwxr-xr-x 2 root shell 4.0K 1970-01-01 01:00 bin
drwxr-xr-x 2 root shell 4.0K 1970-01-01 01:00 cacerts
drwxr-xr-x 3 root shell 4.0K 1970-01-01 01:00 etc
drwxr-xr-x 2 root shell 4.0K 1970-01-01 01:00 javalib
drwxr-xr-x 2 root shell 4.0K 1970-01-01 01:00 lib
drwxr-xr-x 2 root shell 4.0K 1970-01-01 01:00 lib64
drwx------ 2 root root 16K 1970-01-01 01:00 lost+found
Bash
This means that, depending on your GPSU level, your device may or may not use apex-based certificates starting as early as A12. Other devices may still get GPSUs on A11 though, so let’s dig a bit deeper. (Note: At the time of writing, only A14+ devices will use the apex certificates, as explained down below)
My Pixel 3a doesn’t get a GPSU on A11, but it does already have a conscrypt APEX folder installed. Since the conscrypt module supports A11, we should be able to install the newer conscrypt version on our A11 installation, as long as we can find the correct apex file. Apex files shouldn’t be device-specific (that would defeat the entire point) so why don’t we just pull it from the A12 version and install it on A11?
Extracting the apex is actually straightforward, as it’s stored on-disk in /data/apex/active/
:
Next, let’s flash RQ3A.211001.001 (A11)
and install the apex file. Installation should be as easy as installing an APK:
$ adb install [email protected]
Failure [INSTALL_FAILED_DUPLICATE_PACKAGE: Scanning Failed.: com.google.android.conscrypt is an APEX package and can't be installed as an APK.]
Bash
Weird. A different documentation page suggests using –staged while installing, which does work:
$ adb install --staged [email protected]
Performing Streamed Install
Success. Reboot device to apply staged session
Bash
Finally, after rebooting, we do see our newly installed conscrypt version:
$ ls /apex/com.android.conscrypt@351412000
apex_manifest.pb bin cacerts etc javalib lib lib64 lost+found
Bash
Success! Since the signature is valid, the APEX module is loaded and the device now has apex-based CAs! However, after some testing, it turns out that even though the folder is available, the system still falls back to the old /system
location. At some point, Google updated the initialization logic to also check the current SDK version. This logic is currently also included in the latest installable Conscrypt version via Mainline:
static {
String ANDROID_ROOT = System.getenv("ANDROID_ROOT");
String ANDROID_DATA = System.getenv("ANDROID_DATA");
File updatableDir = new File("/apex/com.android.conscrypt/cacerts");
if (shouldUseApex(updatableDir)) {
defaultCaCertsSystemDir = updatableDir;
} else {
defaultCaCertsSystemDir = new File(ANDROID_ROOT + "/etc/security/cacerts");
}
TrustedCertificateStore.setDefaultUserDirectory(new File(ANDROID_DATA + "/misc/keychain"));
}
static boolean shouldUseApex(File updatableDir) {
Object sdkVersion = getSdkVersion();
if (sdkVersion == null || ((Integer) sdkVersion).intValue() < 34) {
return false;
}
if ((System.getProperty("system.certs.enabled") != null && System.getProperty("system.certs.enabled").equals("true")) || !updatableDir.exists() || ArrayUtils.isEmpty(updatableDir.list())) {
return false;
}
return true;
}
Java
So even though the cacerts folder exists via APEX, it won’t be used on anything below A14. That being said, it’s not unthinkable that this logic could be changed in the future. If a root certificate is ever compromised (e.g. like the DigiNotar hack), Google could actually remove the compromised certificate from all Mainline-enabled devices!
In the intro, I mentioned that we did see this behavior on A13. Unfortunately, I could not confirm this, since the device had since received the latest mainline update and it’s not straightforward to collect previous versions of a specific Mainline module. Traffic interception did work after manually mounting the certificate into /apex/ though.
As a final step, let’s clean up and remove the APEX module again. Even though the module is called com.android.conscrypt
, it’s not the correct package name to uninstall it:
$ adb uninstall com.android.conscrypt
Failure [DELETE_FAILED_INTERNAL_ERROR]
Bash
The correct package name is actually contained within the APEX file we installed earlier, which is com.google.android.conscrypt. Why a different package name? 🤷♂️
$ adb -d uninstall com.google.android.conscrypt
Success
$ ls /apex/com.android.conscrypt/
apex_manifest.json apex_manifest.pb bin etc javalib lib lib64 lost+found
Bash
On A15, something weird happens. After installing the AlwaysTrustUser certs module, all of the certificates have disappeared:
It took me a while to figure this out, but luckily the fix is really simple. The problem is two-fold:
/system/etc/security/cacert
onto /apex/com.android.conscrypt/cacerts
/system/etc/security/cacerts
is actually a mount itself: $ mount | grep cert
/dev/block/dm-7 on /system/etc/security/otacerts.zip type ext4 (ro,seclabel,noatime)
/dev/block/dm-7 on /system/etc/security/cacerts/bf64f35b.0 type ext4 (ro,seclabel,noatime)
/dev/block/dm-7 on /system/etc/security/cacerts/5acf816d.0 type ext4 (ro,seclabel,noatime)
/dev/block/dm-7 on /system/etc/security/cacerts/d41b5e2a.0 type ext4 (ro,seclabel,noatime)
/dev/block/dm-7 on /system/etc/security/cacerts/33ee480d.0 type ext4 (ro,seclabel,noatime)
...
Bash
So why have the certificates disappeared? Well, the module collects all the certificates into $MODDIR/system/etc/security/cacerts
which is then automatically overlayed onto the real /system/etc/security/cacerts location.
Then, the /system/etc/security/cacerts
folder is bind-mounted into each process which should make the contents of the folder available. Since every file inside of /system/etc/security/cacerts
is now also a mount, these mounts are not automatically propagated. The fix? Use --rbind
instead of --bind
when entering the process:
# Wrong
/system/bin/nsenter --mount=/proc/$zp/ns/mnt -- /bin/mount --bind $SYS_CERT_DIR $APEX_CERT_DIR
# Correct
/system/bin/nsenter --mount=/proc/$zp/ns/mnt -- /bin/mount --rbind $SYS_CERT_DIR $APEX_CERT_DIR
Bash
With all of these complex mounts, I was surprised to see that disabling root CAs from the settings still worked without any issues. Digging a bit deeper into the implementation, it turns out that root certificates are not removed, but rather copied to the /data/misc/user/0/cacerts-removed
directory when they are disabled in the settings application:
// conscrypt.jar - com.android.org.conscrypt.TrustedCertificateStore
public void deleteCertificateEntry(String alias) throws IOException, CertificateException {
File file;
if (alias == null || (file = fileForAlias(alias)) == null) {
return;
}
if (isSystem(alias)) {
X509Certificate cert = readCertificate(file);
if (cert == null) {
return;
}
// deleteDir = /data/misc/user/0/cacerts-removed/
File deleted = getCertificateFile(this.deletedDir, cert);
if (deleted.exists()) {
return;
}
writeCertificate(deleted, cert);
return;
}
if (isUser(alias)) {
new FileOutputStream(file).close();
removeUnnecessaryTombstones(alias);
}
}
Java
When the TrustedCertificateStore
later looks for the correct root CA, it checks if the identified root CA is available in the cacerts-removed
directory and ignores it if it is:
// conscrypt.jar - com.android.org.conscrypt.TrustedCertificateStore
@Override public X509Certificate getTrustAnchor(final X509Certificate c) {
CertSelector selector = new CertSelector(this) { // from class: com.android.org.conscrypt.TrustedCertificateStore.2
@Override // com.android.org.conscrypt.TrustedCertificateStore.CertSelector
public boolean match(X509Certificate ca) {
return ca.getPublicKey().equals(c.getPublicKey());
}
};
X509Certificate user = (X509Certificate) findCert(this.addedDir, c.getSubjectX500Principal(), selector, X509Certificate.class);
if (user != null) {
return user;
}
X509Certificate system = (X509Certificate) findCert(this.systemDir, c.getSubjectX500Principal(), selector, X509Certificate.class);
if (system != null && !isDeletedSystemCertificate(system)) {
return system;
}
return null;
}
public boolean isDeletedSystemCertificate(X509Certificate x) {
return getCertificateFile(this.deletedDir, x).exists();
}
Java
It took quite some troubleshooting and testing, but my Magisk module has now been updated to cover all (🤞) situations, ranging from Android 7 until Android 16 Beta. Some of the features:
Enjoy, and open a PR if there are any issues! https://github.com/NVISOsecurity/AlwaysTrustUserCerts
Jeroen Beckers is a mobile security expert working in the NVISO Software Security Assessment team. He travels around the world teaching SANS SEC575: iOS and Android Application Security Analysis and Penetration Testing and is a co-author of OWASP Mobile Application Security (MAS) project, which includes: