From 03f9e63f209dabeabf867d9232bcd8f48516a893 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Thu, 30 Jul 2026 19:09:47 +0200 Subject: [PATCH] Write static final fields again on Android 17 Android 17 refuses every reflective write to a static final field: `Field_set` now calls `ThrowIAEIfFieldIsNotOverwritable` before it looks at the accessible flag, and that throws unless the process targets SDK 36 or lower. Clearing the reflective copy's ACC_FINAL does not help, since the check reads the ArtField; a VarHandle unreflected from the same field is read-only, and Android's Unsafe has no static field accessors at all. So `XposedHelpers.setStatic*Field` is dead for every legacy module on that release -- spoofing android.os.Build, which is what most of them use it for, included. HookBridge.makeFieldWritable clears ACC_FINAL where the check reads it, and the setters retry the write through reflection, which keeps the conversions, the type checking and the exceptions where they were. The runtime's own JNI SetStatic*Field is the other way in and is not taken: it is LOG(FATAL) for any field ART holds unmodifiable, and the carve-out sparing android.os.Build is a TODO to be removed. The ArtField's access flags are checked against Field.getModifiers() before anything is written, so a runtime that lays them out differently, or hands out JNI index ids rather than pointers, is left alone and the caller keeps the IllegalAccessError it already had. Nothing changes below 17, where the first reflective write succeeds and none of this is reached. --- .../de/robv/android/xposed/XposedHelpers.java | 95 ++++++++++++------- native/src/jni/hook_bridge.cpp | 38 ++++++++ .../matrix/vector/nativebridge/HookBridge.kt | 17 ++++ 3 files changed, 114 insertions(+), 36 deletions(-) diff --git a/legacy/src/main/java/de/robv/android/xposed/XposedHelpers.java b/legacy/src/main/java/de/robv/android/xposed/XposedHelpers.java index a3bf8dd26..969bd7e90 100644 --- a/legacy/src/main/java/de/robv/android/xposed/XposedHelpers.java +++ b/legacy/src/main/java/de/robv/android/xposed/XposedHelpers.java @@ -8,6 +8,7 @@ import org.apache.commons.lang3.ClassUtilsX; import org.apache.commons.lang3.reflect.MemberUtilsX; +import org.matrix.vector.nativebridge.HookBridge; import java.io.ByteArrayOutputStream; import java.io.FileInputStream; @@ -1149,12 +1150,11 @@ public static short getShortField(Object obj, String fieldName) { * Sets the value of a static object field in the given class. See also {@link #findField}. */ public static void setStaticObjectField(Class clazz, String fieldName, Object value) { + Field field = findField(clazz, fieldName); try { - findField(clazz, fieldName).set(null, value); + field.set(null, value); } catch (IllegalAccessException e) { - // should not happen - XposedBridge.log(e); - throw new IllegalAccessError(e.getMessage()); + setStaticFinalField(field, value, e); } catch (IllegalArgumentException e) { throw e; } @@ -1164,12 +1164,11 @@ public static void setStaticObjectField(Class clazz, String fieldName, Object * Sets the value of a static {@code boolean} field in the given class. See also {@link #findField}. */ public static void setStaticBooleanField(Class clazz, String fieldName, boolean value) { + Field field = findField(clazz, fieldName); try { - findField(clazz, fieldName).setBoolean(null, value); + field.setBoolean(null, value); } catch (IllegalAccessException e) { - // should not happen - XposedBridge.log(e); - throw new IllegalAccessError(e.getMessage()); + setStaticFinalField(field, value, e); } catch (IllegalArgumentException e) { throw e; } @@ -1179,12 +1178,11 @@ public static void setStaticBooleanField(Class clazz, String fieldName, boole * Sets the value of a static {@code byte} field in the given class. See also {@link #findField}. */ public static void setStaticByteField(Class clazz, String fieldName, byte value) { + Field field = findField(clazz, fieldName); try { - findField(clazz, fieldName).setByte(null, value); + field.setByte(null, value); } catch (IllegalAccessException e) { - // should not happen - XposedBridge.log(e); - throw new IllegalAccessError(e.getMessage()); + setStaticFinalField(field, value, e); } catch (IllegalArgumentException e) { throw e; } @@ -1194,12 +1192,11 @@ public static void setStaticByteField(Class clazz, String fieldName, byte val * Sets the value of a static {@code char} field in the given class. See also {@link #findField}. */ public static void setStaticCharField(Class clazz, String fieldName, char value) { + Field field = findField(clazz, fieldName); try { - findField(clazz, fieldName).setChar(null, value); + field.setChar(null, value); } catch (IllegalAccessException e) { - // should not happen - XposedBridge.log(e); - throw new IllegalAccessError(e.getMessage()); + setStaticFinalField(field, value, e); } catch (IllegalArgumentException e) { throw e; } @@ -1209,12 +1206,11 @@ public static void setStaticCharField(Class clazz, String fieldName, char val * Sets the value of a static {@code double} field in the given class. See also {@link #findField}. */ public static void setStaticDoubleField(Class clazz, String fieldName, double value) { + Field field = findField(clazz, fieldName); try { - findField(clazz, fieldName).setDouble(null, value); + field.setDouble(null, value); } catch (IllegalAccessException e) { - // should not happen - XposedBridge.log(e); - throw new IllegalAccessError(e.getMessage()); + setStaticFinalField(field, value, e); } catch (IllegalArgumentException e) { throw e; } @@ -1224,12 +1220,11 @@ public static void setStaticDoubleField(Class clazz, String fieldName, double * Sets the value of a static {@code float} field in the given class. See also {@link #findField}. */ public static void setStaticFloatField(Class clazz, String fieldName, float value) { + Field field = findField(clazz, fieldName); try { - findField(clazz, fieldName).setFloat(null, value); + field.setFloat(null, value); } catch (IllegalAccessException e) { - // should not happen - XposedBridge.log(e); - throw new IllegalAccessError(e.getMessage()); + setStaticFinalField(field, value, e); } catch (IllegalArgumentException e) { throw e; } @@ -1239,12 +1234,11 @@ public static void setStaticFloatField(Class clazz, String fieldName, float v * Sets the value of a static {@code int} field in the given class. See also {@link #findField}. */ public static void setStaticIntField(Class clazz, String fieldName, int value) { + Field field = findField(clazz, fieldName); try { - findField(clazz, fieldName).setInt(null, value); + field.setInt(null, value); } catch (IllegalAccessException e) { - // should not happen - XposedBridge.log(e); - throw new IllegalAccessError(e.getMessage()); + setStaticFinalField(field, value, e); } catch (IllegalArgumentException e) { throw e; } @@ -1254,12 +1248,11 @@ public static void setStaticIntField(Class clazz, String fieldName, int value * Sets the value of a static {@code long} field in the given class. See also {@link #findField}. */ public static void setStaticLongField(Class clazz, String fieldName, long value) { + Field field = findField(clazz, fieldName); try { - findField(clazz, fieldName).setLong(null, value); + field.setLong(null, value); } catch (IllegalAccessException e) { - // should not happen - XposedBridge.log(e); - throw new IllegalAccessError(e.getMessage()); + setStaticFinalField(field, value, e); } catch (IllegalArgumentException e) { throw e; } @@ -1269,12 +1262,11 @@ public static void setStaticLongField(Class clazz, String fieldName, long val * Sets the value of a static {@code short} field in the given class. See also {@link #findField}. */ public static void setStaticShortField(Class clazz, String fieldName, short value) { + Field field = findField(clazz, fieldName); try { - findField(clazz, fieldName).setShort(null, value); + field.setShort(null, value); } catch (IllegalAccessException e) { - // should not happen - XposedBridge.log(e); - throw new IllegalAccessError(e.getMessage()); + setStaticFinalField(field, value, e); } catch (IllegalArgumentException e) { throw e; } @@ -1282,6 +1274,37 @@ public static void setStaticShortField(Class clazz, String fieldName, short v //################################################################################################# + /** + * Writes a static field reflection has just refused to write. + * + * Android 17 rejects every reflective write to a static final field, whatever the Field's + * accessible flag says, so on that release the nine setters above would do nothing but throw + * for a module doing what modules have always done -- spoofing android.os.Build being the + * common one. The framework drops the field's final flag and lets reflection write it after + * all, which is the whole of the difference: the value, the conversions and the type checking + * are still reflection's. + * + * Only reached once reflection has thrown, so nothing changes on the releases that allow the + * write, and a genuine access failure -- or a runtime this cannot read -- still ends in the + * IllegalAccessError it always did. + */ + private static void setStaticFinalField(Field field, Object value, IllegalAccessException cause) { + if (HookBridge.makeFieldWritable(field, field.getModifiers())) { + try { + // Boxed, whatever the field's type: Field.set unboxes for a primitive field and + // widens like the typed setter that has just failed would have. + field.set(null, value); + return; + } catch (IllegalAccessException retried) { + cause = retried; + } + } + XposedBridge.log(cause); + throw new IllegalAccessError(cause.getMessage()); + } + + //################################################################################################# + /** * Returns the value of a static object field in the given class. See also {@link #findField}. */ diff --git a/native/src/jni/hook_bridge.cpp b/native/src/jni/hook_bridge.cpp index 2cbae6777..29ff2ec64 100644 --- a/native/src/jni/hook_bridge.cpp +++ b/native/src/jni/hook_bridge.cpp @@ -89,6 +89,7 @@ SharedHashMap> hooked_methods; // Cached JNI method and field IDs for performance. jmethodID invoke = nullptr; + } // namespace namespace vector::native::jni { @@ -504,6 +505,41 @@ VECTOR_DEF_NATIVE_METHOD(jboolean, HookBridge, setTrusted, jobject cookie) { return lsplant::MakeDexFileTrusted(env, cookie); } +/** + * @brief Clears ACC_FINAL on a field, so that reflection will write it again. + * + * Android 17 refuses every reflective write to a static final field + * (`ThrowIAEIfFieldIsNotOverwritable` in `runtime/native/java_lang_reflect_Field.cc`), whatever + * the Field's accessible flag says, and clearing the reflective copy's ACC_FINAL does not help + * because the check reads the ArtField. This clears it where the check looks. + * + * The runtime's own JNI SetStatic*Field is the other way in, and is not taken here: it is + * `LOG(FATAL)` for anything ART considers unmodifiable, and the carve-out that would spare + * android.os.Build carries a TODO to remove it. A field that is no longer final is unmodifiable + * to nobody, so this stays a write and never becomes an abort. + * + * [modifiers] is what java.lang.reflect.Field reports, and the ArtField's access flags have to + * agree with it before anything is written: that is what says this pointer is an ArtField laid + * out the way this expects, rather than a JNI index id or a layout that has moved. + * + * @return JNI_TRUE when the field is no longer final. + */ +VECTOR_DEF_NATIVE_METHOD(jboolean, HookBridge, makeFieldWritable, jobject field, jint modifiers) { + // jfieldID is the ArtField itself, and `access_flags_` follows the four-byte compressed + // `declaring_class_` root that starts it. + auto *art_field = reinterpret_cast(env->FromReflectedField(field)); + if (art_field == nullptr) return JNI_FALSE; + + constexpr uint32_t kAccJavaFlagsMask = 0xFFFFu; + constexpr uint32_t kAccFinal = 0x0010u; + + uint32_t flags = art_field[1]; + if ((flags & kAccJavaFlagsMask) != static_cast(modifiers)) return JNI_FALSE; + + art_field[1] = flags & ~kAccFinal; + return JNI_TRUE; +} + /** * @brief Creates a snapshot of all registered callbacks for a given method. * This is useful for debugging and introspection from the Java side. @@ -686,6 +722,7 @@ static JNINativeMethod gMethods[] = { VECTOR_NATIVE_METHOD(HookBridge, allocateObject, "(Ljava/lang/Class;)Ljava/lang/Object;"), VECTOR_NATIVE_METHOD(HookBridge, instanceOf, "(Ljava/lang/Object;Ljava/lang/Class;)Z"), VECTOR_NATIVE_METHOD(HookBridge, setTrusted, "(Ljava/lang/Object;)Z"), + VECTOR_NATIVE_METHOD(HookBridge, makeFieldWritable, "(Ljava/lang/reflect/Field;I)Z"), VECTOR_NATIVE_METHOD(HookBridge, callbackSnapshot, "(Ljava/lang/Class;Ljava/lang/reflect/" "Executable;)[[Ljava/lang/Object;"), @@ -702,6 +739,7 @@ void RegisterHookBridge(JNIEnv *env) { invoke = env->GetMethodID(method, "invoke", "(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;"); env->DeleteLocalRef(method); + REGISTER_VECTOR_NATIVE_METHODS(HookBridge); } } // namespace vector::native::jni diff --git a/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt index d469eb4a5..9478806fb 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt @@ -2,6 +2,7 @@ package org.matrix.vector.nativebridge import dalvik.annotation.optimization.FastNative import java.lang.reflect.Executable +import java.lang.reflect.Field import java.lang.reflect.InvocationTargetException import java.lang.reflect.Method @@ -54,6 +55,22 @@ object HookBridge { @JvmStatic @FastNative external fun setTrusted(cookie: Any?): Boolean + /** + * Clears the final flag ART reads, so that reflection will write [field] again. + * + * Android 17 refuses every reflective write to a static final field however accessible the + * [Field] is, and clearing the reflective copy's flag does not help because the check reads + * the ArtField. The write itself stays with reflection, which keeps its conversions and its + * exceptions; this only stops it being refused. + * + * [modifiers] must be `field.modifiers`. It is checked against the flags about to be written, + * so a runtime that lays an ArtField out differently is left alone rather than corrupted. + * + * Returns false when the field is still final, which is the caller's cue to report the + * failure it already had. The field stays writable afterwards. + */ + @JvmStatic external fun makeFieldWritable(field: Field, modifiers: Int): Boolean + /** Returns null when [method] carries no hooks at all. */ @JvmStatic external fun callbackSnapshot(