diff --git a/README.md b/README.md index 513eb9a..a4c2dba 100644 --- a/README.md +++ b/README.md @@ -194,17 +194,27 @@ To update Android or Magisk: 4. Reboot. -## Blocking A/B OTA Updates +## avbroot Magisk modules -Unpatched OTA updates are already blocked in recovery because the original OTA certificate has been replaced with the custom certificate. To disable OTAs while booted into Android, turn off `Automatic system updates` in Android's Developer Options. - -To intentionally make A/B OTAs fail while booted into Android (to prevent accidental manual updates), build the `clearotacerts` module: +avbroot's Magisk modules can be built by running: ```bash -python clearotacerts/build.py +python modules/build.py ``` -and flash the `clearotacerts/dist/clearotacerts-.zip` file in Magisk. The module simply overrides `/system/etc/security/otacerts.zip` at runtime with an empty zip so that even if an OTA is downloaded, signature verification will fail. +This requires Java and the Android SDK to be installed. The `ANDROID_HOME` environment variable should be set to the Android SDK path. + +### `clearotacerts`: Blocking A/B OTA Updates + +Unpatched OTA updates are already blocked in recovery because the original OTA certificate has been replaced with the custom certificate. To disable automatic OTAs while booted into Android, turn off `Automatic system updates` in Android's Developer Options. + +The `clearotacerts` module additionally makes A/B OTAs fail while booted into Android to prevent accidental manual updates. The module simply overrides `/system/etc/security/otacerts.zip` at runtime with an empty zip so that even if an OTA is downloaded, signature verification will fail. + +### `oemunlockonboot`: Enable OEM unlocking on every boot + +To help reduce the risk of OEM unlocking being accidentally disabled (or intentionally disabled as part of some OS's initial setup wizard), this module will attempt to enable the OEM unlocking option on every boot. + +The logs for this module can be found at `/data/local/tmp/avbroot_oem_unlock.log`. ## Magisk preinit device diff --git a/clearotacerts/build.py b/clearotacerts/build.py deleted file mode 100755 index 5f84ecc..0000000 --- a/clearotacerts/build.py +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env python3 - -import io -import os -import shutil -import sys -import zipfile - - -def build_empty_zip(): - stream = io.BytesIO() - - with zipfile.ZipFile(stream, 'w'): - pass - - return stream.getvalue() - - -def parse_props(raw_prop): - result = {} - - for line in raw_prop.decode('UTF-8').splitlines(): - k, delim, v = line.partition('=') - if not delim: - raise ValueError(f'Malformed line: {repr(line)}') - - result[k.strip()] = v.strip() - - return result - - -def main(): - dist_dir = os.path.join(sys.path[0], 'dist') - os.makedirs(dist_dir, exist_ok=True) - - with open(os.path.join(sys.path[0], 'module.prop'), 'rb') as f: - module_prop_raw = f.read() - module_prop = parse_props(module_prop_raw) - - name = module_prop['name'] - version = module_prop['version'].removeprefix('v') - zip_path = os.path.join(dist_dir, f'{name}-{version}.zip') - - with zipfile.ZipFile(zip_path, 'w') as z: - for name, data in ( - ('META-INF/com/google/android/update-binary', None), - ('META-INF/com/google/android/updater-script', None), - ('module.prop', module_prop_raw), - ('system/etc/security/otacerts.zip', build_empty_zip()), - ): - # Build our own ZipInfo to ensure archive is reproducible - info = zipfile.ZipInfo(name) - with z.open(info, 'w') as f_out: - if data is not None: - f_out.write(data) - else: - with open(os.path.join(sys.path[0], name), 'rb') as f_in: - shutil.copyfileobj(f_in, f_out) - - -if __name__ == '__main__': - main() diff --git a/modules/build.py b/modules/build.py new file mode 100755 index 0000000..c9a591a --- /dev/null +++ b/modules/build.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 + +import argparse +import io +import os +import re +import shutil +import subprocess +import sys +import tempfile +import zipfile + + +def natsort_key(text, regex=re.compile(r'(\d+)')): + return [int(s) if s.isdigit() else s for s in regex.split(text)] + + +def newest_child_by_name(directory): + children = os.listdir(directory) + if not children: + raise ValueError(f'{directory} has no children') + + child = sorted(children, key=natsort_key)[-1] + return os.path.join(directory, child) + + +def build_empty_zip(): + stream = io.BytesIO() + + with zipfile.ZipFile(stream, 'w'): + pass + + return stream.getvalue() + + +def build_dex(sources): + if 'ANDROID_HOME' not in os.environ: + raise ValueError('ANDROID_HOME must be set to the Android SDK path') + + sdk = os.environ['ANDROID_HOME'] + build_tools = newest_child_by_name(os.path.join(sdk, 'build-tools')) + platform = newest_child_by_name(os.path.join(sdk, 'platforms')) + d8 = os.path.join(build_tools, 'd8') + android_jar = os.path.join(platform, 'android.jar') + + with tempfile.TemporaryDirectory() as temp_dir: + subprocess.check_call([ + 'javac', + '-source', '1.8', + '-target', '1.8', + '-cp', android_jar, + '-d', temp_dir, + *sources, + ]) + + class_files = [] + for root, _, files in os.walk(temp_dir): + for f in files: + if f.endswith('.class'): + class_files.append(os.path.join(root, f)) + + subprocess.check_call([ + d8, + '--output', temp_dir, + *class_files, + ]) + + with open(os.path.join(temp_dir, 'classes.dex'), 'rb') as f: + return f.read() + + +def parse_props(raw_prop): + result = {} + + for line in raw_prop.decode('UTF-8').splitlines(): + k, delim, v = line.partition('=') + if not delim: + raise ValueError(f'Malformed line: {repr(line)}') + + result[k.strip()] = v.strip() + + return result + + +def build_module(dist_dir, common_dir, module_dir, extra_files): + with open(os.path.join(module_dir, 'module.prop'), 'rb') as f: + module_prop_raw = f.read() + module_prop = parse_props(module_prop_raw) + + name = module_prop['name'] + version = module_prop['version'].removeprefix('v') + zip_path = os.path.join(dist_dir, f'{name}-{version}.zip') + + with zipfile.ZipFile(zip_path, 'w') as z: + file_map = { + 'META-INF/com/google/android/update-binary': { + 'file': os.path.join(common_dir, 'update-binary'), + }, + 'META-INF/com/google/android/updater-script': { + 'file': os.path.join(common_dir, 'updater-script'), + }, + 'module.prop': { + 'data': module_prop_raw, + }, + **extra_files, + } + + for name, source in sorted(file_map.items()): + # Build our own ZipInfo to ensure archive is reproducible + info = zipfile.ZipInfo(name) + with z.open(info, 'w') as f_out: + if 'data' in source: + f_out.write(source['data']) + else: + with open(source['file'], 'rb') as f_in: + shutil.copyfileobj(f_in, f_out) + + return zip_path + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument('module', nargs='*', + default=('clearotacerts', 'oemunlockonboot'), + help='Module to build') + + return parser.parse_args() + + +def main(): + args = parse_args() + + dist_dir = os.path.join(sys.path[0], 'dist') + os.makedirs(dist_dir, exist_ok=True) + + common_dir = os.path.join(sys.path[0], 'common') + + for module in args.module: + module_dir = os.path.join(sys.path[0], module) + + if module == 'clearotacerts': + extra_files = { + 'system/etc/security/otacerts.zip': { + 'data': build_empty_zip(), + }, + } + elif module == 'oemunlockonboot': + extra_files = { + 'classes.dex': { + 'data': build_dex([os.path.join(module_dir, 'Main.java')]), + }, + 'service.sh': { + 'file': os.path.join(module_dir, 'service.sh'), + }, + } + else: + raise ValueError(f'Invalid module: {module}') + + module_zip = build_module(dist_dir, common_dir, module_dir, extra_files) + print('Built module', module_zip) + + +if __name__ == '__main__': + main() diff --git a/clearotacerts/module.prop b/modules/clearotacerts/module.prop similarity index 100% rename from clearotacerts/module.prop rename to modules/clearotacerts/module.prop diff --git a/clearotacerts/META-INF/com/google/android/update-binary b/modules/common/update-binary similarity index 100% rename from clearotacerts/META-INF/com/google/android/update-binary rename to modules/common/update-binary diff --git a/clearotacerts/META-INF/com/google/android/updater-script b/modules/common/updater-script similarity index 100% rename from clearotacerts/META-INF/com/google/android/updater-script rename to modules/common/updater-script diff --git a/modules/oemunlockonboot/Main.java b/modules/oemunlockonboot/Main.java new file mode 100644 index 0000000..f066ce3 --- /dev/null +++ b/modules/oemunlockonboot/Main.java @@ -0,0 +1,83 @@ +import android.annotation.SuppressLint; +import android.os.IBinder; +import android.os.IInterface; +import android.os.Process; +import android.system.ErrnoException; + +import java.lang.reflect.Method; + +@SuppressLint({"DiscouragedPrivateApi", "PrivateApi", "SoonBlockedPrivateApi"}) +public class Main { + private static final int GET_SERVICE_ATTEMPTS = 30; + + @SuppressWarnings("SameParameterValue") + private static IInterface getService(Class interfaceClass, String serviceName) throws Exception { + Class serviceManager = Class.forName("android.os.ServiceManager"); + Method getService = serviceManager.getDeclaredMethod("getService", String.class); + + Class stub = Class.forName(interfaceClass.getCanonicalName() + "$Stub"); + Method asInterface = stub.getDeclaredMethod("asInterface", IBinder.class); + + // ServiceManager.waitForService() tries to start the service, which we want to avoid to be + // 100% sure we're not disrupting the boot flow. + for (int attempt = 1; attempt <= GET_SERVICE_ATTEMPTS; ++attempt) { + IBinder iBinder = (IBinder) getService.invoke(null, serviceName); + if (iBinder != null) { + return (IInterface) asInterface.invoke(null, iBinder); + } + + if (attempt < GET_SERVICE_ATTEMPTS) { + Thread.sleep(1000); + } + } + + throw new IllegalStateException( + "Service " + serviceName + " not found after " + GET_SERVICE_ATTEMPTS + " attempts"); + } + + @SuppressWarnings("ConstantConditions") + private static void unlock() throws Exception { + Class iOemLockService = Class.forName("android.service.oemlock.IOemLockService"); + IInterface iFace = getService(iOemLockService, "oem_lock"); + + Method setOemUnlockAllowedByUser = iOemLockService.getDeclaredMethod("setOemUnlockAllowedByUser", boolean.class); + Method isOemUnlockAllowedByUser = iOemLockService.getDeclaredMethod("isOemUnlockAllowedByUser"); + + Boolean unlockAllowed = (Boolean) isOemUnlockAllowedByUser.invoke(iFace); + if (unlockAllowed) { + System.out.println("OEM unlocking already enabled"); + return; + } + + System.out.println("Enabling OEM unlocking"); + setOemUnlockAllowedByUser.invoke(iFace, true); + } + + @SuppressWarnings({"ConstantConditions", "JavaReflectionMemberAccess"}) + private static void switchToSystemUid() throws Exception { + if (Process.myUid() != Process.SYSTEM_UID) { + Method setUid = Process.class.getDeclaredMethod("setUid", int.class); + int errno = (int) setUid.invoke(null, Process.SYSTEM_UID); + + if (errno != 0) { + throw new Exception("Failed to switch to SYSTEM (" + Process.SYSTEM_UID + ") user", + new ErrnoException("setuid", errno)); + } + if (Process.myUid() != Process.SYSTEM_UID) { + throw new IllegalStateException("UID didn't actually change: " + + Process.myUid() + " != " + Process.SYSTEM_UID); + } + } + } + + public static void main(String[] args) { + try { + switchToSystemUid(); + unlock(); + } catch (Exception e) { + System.err.println("Failed to enable OEM unlocking"); + e.printStackTrace(); + System.exit(1); + } + } +} diff --git a/modules/oemunlockonboot/module.prop b/modules/oemunlockonboot/module.prop new file mode 100644 index 0000000..13a8c1c --- /dev/null +++ b/modules/oemunlockonboot/module.prop @@ -0,0 +1,6 @@ +id=com.chiller3.avbroot.oemunlockonboot +name=oemunlockonboot +version=v1.0 +versionCode=1 +author=chenxiaolong +description=Enable OEM unlocking on every boot diff --git a/modules/oemunlockonboot/service.sh b/modules/oemunlockonboot/service.sh new file mode 100644 index 0000000..757f832 --- /dev/null +++ b/modules/oemunlockonboot/service.sh @@ -0,0 +1,20 @@ +exec >/data/local/tmp/avbroot_oem_unlock.log 2>&1 + +mod_dir=${0%/*} + +header() { + echo "----- ${*} -----" +} + +header Environment +echo "Timestamp: $(date)" +echo "Script: ${0}" +echo "UID/GID/Context: $(id)" + +header Enable OEM unlocking +CLASSPATH="${mod_dir}/classes.dex" app_process / Main & +pid=${!} +wait "${pid}" +echo "Exit status: ${?}" +echo "Logcat:" +logcat -d --pid "${pid}"