Skip to content

Commit

Permalink
Add Magisk module to enable OEM unlocking on every boot
Browse files Browse the repository at this point in the history
The module helps reduce the chance of OEM unlocking being disabled,
whether by the user or by some OS's initial setup wizard. It works by
running some Java code at boot, which connects to the `OemLockService`
binder service and calls `setOemUnlockAllowedByUser(true)`, the same as
what the Settings app does.

Fixes: #8
Issue: #84

Signed-off-by: Andrew Gunnerson <[email protected]>
  • Loading branch information
chenxiaolong committed May 23, 2023
1 parent 7d5977b commit 0c6c0b2
Show file tree
Hide file tree
Showing 9 changed files with 275 additions and 68 deletions.
22 changes: 16 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>.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

Expand Down
62 changes: 0 additions & 62 deletions clearotacerts/build.py

This file was deleted.

150 changes: 150 additions & 0 deletions modules/build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
#!/usr/bin/env python3

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 main():
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 ('clearotacerts', 'oemunlockonboot'):
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'),
},
}

module_zip = build_module(dist_dir, common_dir, module_dir, extra_files)
print('Built module', module_zip)


if __name__ == '__main__':
main()
File renamed without changes.
File renamed without changes.
File renamed without changes.
83 changes: 83 additions & 0 deletions modules/oemunlockonboot/Main.java
Original file line number Diff line number Diff line change
@@ -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");
}

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);
//noinspection ConstantConditions
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);
}
}
}
6 changes: 6 additions & 0 deletions modules/oemunlockonboot/module.prop
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions modules/oemunlockonboot/service.sh
Original file line number Diff line number Diff line change
@@ -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}"

0 comments on commit 0c6c0b2

Please sign in to comment.