Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение5 мин
Количество просмотров3.7K

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

Содержание

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

Часть 2. Классы-двойники. Получение списка всех полей, методов и конструкторов класса. Конвертация конструкторов в методы. Статический конструктор.

Введение

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

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

Теги:
Хабы:
Всего голосов 8: ↑8 и ↓0+8
Комментарии6

Публикации

Истории

Работа

Java разработчик
350 вакансий

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань