Java очень глубоко интегрирована в android и имеет в данной ОС свою нестандартную виртуальную машину — DVM/ART, поэтому многие детали реализации отличаются от привычных. А что насчёт внутреннего API sun.misc.Unsafe? В этом цикле статей с его помощью мы попытаемся максимально сломать виртуальную машину андроида.
Содержание
Введение
Начать стоит с того, что данный класс из себя представляет и для чего обычно используется. sun.misc.Unsafe существует с очень ранних версий java и необходим для выполнения действий, которые не предусмотрены языком, а их реализация в нативном коде по каким-то причинам нежелательна. Какие же возможности он предоставляет?
Object allocateInstance(Class<?> cls) — создание объекта, без вызова конструктора. Все поля заполняются стандартными значениями (0, 0.0, null и т.п.)
XXX getXXX(Object obj, long offset) — чтение памяти, используя объект как указатель. Если он равен null, то смещение выступает в качестве нативного адреса
void setXXX(Object obj, long offset, XXX value) — запись в память по тем же правилам, что и при чтении
int arrayBaseOffset(Class<?> cls), int arrayIndexScale(Class<?> cls) — определение параметров массива (смещение от начала массива до первого элемента и размер элементов соответственно)
long allocateMemory(long bytes), void freeMemory(long address) — обёртки над malloc и free из Си — выделение и освобождение памяти вне кучи
void throwException(Throwable th) — «скрытное» бросание исключений (без их объявления в throws). Увы, но данный метод отсутствует в Unsafe андроида, поэтому придётся создавать его самостоятельно
А так же куча других полезных вещей, особенно для многопоточного программирования, которые в данный момент нам не нужны
Подготовка
Как получить доступ ко всему этому инструментарию? Класс Unsafe содержит публичный метод getUnsafe, только вот незадача — в нём есть проверка безопасности:
public final class Unsafe {
<...>
private static final Unsafe theUnsafe = new Unsafe();
<...>
@CallerSensitive
public static Unsafe getUnsafe() {
Class<?> caller = Reflection.getCallerClass();
// Этот код не даёт получить экземпляр ползовательским классам
if (!VM.isSystemDomainLoader(caller.getClassLoader()))
throw new SecurityException("Unsafe");
return theUnsafe;
}
<...>
}
Один из способов обойти её — прямой доступ к статическому полю theUnsafe через рефлексию
public static Unsafe getUnsafe() {
try {
// получаем поле
Field field = Unsafe.class.getDeclaredField("theUnsafe");
// делаем его доступным для использования
field.setAccessible(true);
// получаем экземпляр sun.misc.Unsafe
return (Unsafe) field.get(null);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
Этот код настолько часто используется в разнообразных библиотеках, что его можно назвать классическим. Он даже не вызывает предупреждений во время выполнения (а вот доступ к конструктору вместо поля — ещё как).
Теперь нужно восполнить отсутствие метода throwException с помощью сломанного класса Thrower — мы создадим в нём метод с нужной сигнатурой, и насильно удалим throws Throwable:
Реализация
Вариант кода на Smali — ассемблере Dalvik:
.class public Lcom/v7878/Thrower;
.super Ljava/lang/Object;
.method public constructor <init>()V
.registers 1
invoke-direct { p0 }, Ljava/lang/Object;-><init>()V
return-void
.end method
.method public static throwException(Ljava/lang/Throwable;)V
.registers 1
throw p0
.end method
То же самое, но на java:
// Не скомпилируется,так как нет throws Throwable
public class Thrower {
public static void throwException(Throwable th) {
throw th;
}
}
Эксперименты
Время перейти к самой интересной части — экспериментам.
arrayCast
Что произойдёт если мы попробуем обратиться к объекту класса A как к объекту класса B, например попытаемся вызвать метод или получить поле, которого у него нет? Язык защищает от таких поступков ещё на этапе компиляции, и не даёт нам творить беспредел, но ведь теперь мы можем это игнорировать, да? Обмануть компилятор (а заодно и верификатор байт-кода) можно положив объект в поле неправильного типа и обращаясь уже к нему:
// Смещение от начала массива до первого элемента
// int ARRAY_OBJECT_BASE_OFFSET = arrayBaseOffset(Object[].class);
class A {
public int a;
}
class B {
public int b;
}
A obj = new A();
obj.a = 100;
B[] array = new B[1];
// array[0] = obj;
putObject(array, ARRAY_OBJECT_BASE_OFFSET, obj);
System.out.println(array[0].b);
// System.out: 100
В данном примере мы положили объект типа A (со значением 100 в поле a) как первый элемент массива B и получили значение 100 из поля b. Как это произошло?
Объяснение
Объект java представляет собой ссылку на память в куче, где лежат его данные — поля. Каждое поле имеет своё смещение от начала объекта (они отсортированы по уменьшению размера и по алфавиту. Сначала идут поля суперкласса). Например класс java.lang.Object имеет всего 2 поля:
public class Object {
<...>
private transient Class<?> shadow$_klass_;
private transient int shadow$_monitor_;
<...>
}
shadow$_klass_ содержит тип объекта, а shadow$_monitor_ данные монитора (используется для методов wait* и notify* и блоков synchronized) и/или кешированый хешкод. Первое поле будет иметь смещение 0 и размер 4 (размер поля не примитивного типа на андроиде равен 4 (даже на 64-битных устройствах!)), второе поле — смещение 4 и такой же размер.
Переходя к изначальному примеру — поле a типа A имеет то же самое смещение, что и поле b типа B, поэтому доступ ко второму даёт первое. Обратите внимание, что ни один объект не сменил реальный тип.
Теперь можно обобщить полученный опыт и сделать отдельный метод, выполняющий функцию оператора reinterpret_cast из C++
// Смещение от начала массива до первого элемента
// int ARRAY_OBJECT_BASE_OFFSET = arrayBaseOffset(Object[].class);
// размер элемента массива объектов (на андроиде всегда должен быть 4)
// int ARRAY_OBJECT_INDEX_SCALE = arrayIndexScale(Object[].class);
public <T> T[] arrayCast(Class<T> clazz, Object... data) {
// нам нужен массив объектов, а не чего-то ещё
if(clazz.isPrimitive()) {
throw new IllegalArgumentException();
}
// создаём массив типа T
T[] out = (T[]) Array.newInstance(clazz, data.length);
// переносим все объекты в массив
for (int i = 0; i < data.length; i++) {
putObject(out, ARRAY_OBJECT_BASE_OFFSET + i * ARRAY_OBJECT_INDEX_SCALE, data[i]);
}
return out;
}
Свежеиспечённый метод принимает класс, к которому мы хотим привести группу объектов, и сами объекты, кладёт их в массив нужного типа и возвращает. Удивительно, но андроид ни разу не проверяет, что именно лежит в массиве, поэтому всё проходит гладко.
Теперь давайте применим arrayCast для изменения private final поля, чего нельзя сделать с помощью обычной рефлексии:
class A {
private final int value;
public A(int value) {
this.value = value;
}
@Override
public String toString() {
return Integer.toString(value);
}
}
class B {
public int value;
}
A obj = new A(100);
// приводим obj к типу B
B[] array = arrayCast(B.class, obj);
// меняем значение
array[0].value = -1;
// выводим в консоль
System.out.println(obj);
// System.out: -1
Получается, что при известном строении объекта с его полями можно творить что угодно, но как насчёт методов?
class A {
private final int value;
public A(int value) {
this.value = value;
}
@Override
public String toString() {
return Integer.toString(value);
}
}
class B {
public int value;
public void set(int x) {
value = x;
}
}
A obj = new A(100);
// приводим obj к типу B
B[] array = arrayCast(B.class, obj);
// меняем значение
array[0].set(-1);
// выводим в консоль
System.out.println(obj);
// ожидаем получить: System.out: -1
// получаем: zygote A [runtime.cc:492] Runtime aborting...
// и огромный столб текста с аварийным дампом
Что произошло? Почему такие ужасные последствия у, казалось бы, маленьких изменений? Причина кроется в способе вызова методов — по индексу в списке внутри класса объекта. Мы вызываем метод B.set(int), допустим он имеет индекс 1, но вызываем то мы его на объекте типа A! У него другой список, и под индексом 1, может быть, идёт конструктор. Это несоответствие и вызывает ошибку.
Но почему такого не происходит при доступе к полям? Ответ прост — они всегда имеют постоянное смещение и его нет нужды вычислять каждый раз по индексу поля в классе, а значит, для оптимизации всегда идёт прямой доступ по заранее просчитанному смещению.
В будущем мы периодически будем обращаться к arrayCast для достижения своих целей. На этом вводная часть подходит к концу.
Весь исходный код можно найти здесь.