Небезопасный android: эксперименты с sun.misc.Unsafe. Часть 1

от автора

Java очень глубоко интегрирована в android и имеет в данной ОС свою нестандартную виртуальную машину — Dalvik VM, поэтому многие детали реализации отличаются от привычных. А что насчёт внутреннего API sun.misc.Unsafe? В этом цикле статей с его помощью мы попытаемся максимально сломать виртуальную машину андроида.

Содержание

Часть 1. Введение. Создание arrayCast и немного про его применение

Введение

Начать стоит с того, что данный класс из себя представляет и для чего обычно используется. 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 для достижения своих целей. На этом вводная часть подходит к концу.

*Продолжение следует*

Весь исходный код можно найти здесь.


ссылка на оригинал статьи https://habr.com/ru/post/720788/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *