Добрый день. Делюсь своей старой исследовательской мини-статьей. Не ругайтесь, мне просто было скучновато.
Я, как и любой программист, люблю изучать что-то новое. Все мы знаем о киношных хакерах, которые могут парой нажатий кнопок на любом холодильнике, взломать инсту маминой подруги.
Очередное желание изучить что-то новое привело меня к попытке написать программу (на rust), которая должна без инъекции взаимодействовать с запущенным java приложением. Я хотел читать и изменять память нужных мне java объектов без загрузки java-agent модулей, без использования jni, без загрузки новых классов в приложение и т.п. У меня есть PID процесса и знание о том, что это java приложение.
Подготовка
Мой этичный хаккинг начался с простого. Чтобы видеть свой результат, я написал простое java приложение, которое создавало некого Player, который имел только имя и наследовался от Entity, которое в свою очередь имело в качестве единственного поля вектор, указывающий на позицию существа. Задача состояла в поиске и изменении этого вектора.
static class Entity { protected Position pos = new Position(0.0, 0.0, 0.0); } static class Player extends Entity { private final String name; public Player(String name) { this.name = name; } } static class Position { private double x; private double y; private double z; public Position(double x, double y, double z) { this.x = x; this.y = y; this.z = z; } }
Для того, чтобы сразу было видно и не пришлось использовать отладчики, я вывел адрес своего игрока в терминал.
public static String printAddresses(Object... objects) throws Exception { StringBuilder result = new StringBuilder(); long last = 0; Unsafe unsafe = getUnsafe(); int offset = unsafe.arrayBaseOffset(objects.getClass()); long factor = is64bit() ? 8 : 1; long firstAddress = (unsafe.getInt(objects, offset) & 0xFFFFFFFFL) * factor; result.append(Long.toHexString(firstAddress)); last = firstAddress; for (int i = 1; i < objects.length; i++) { long currentAddress = (unsafe.getInt(objects, offset + i * 4) & 0xFFFFFFFFL) * factor; long difference = currentAddress - last; if (difference > 0) { result.append(", +").append(Long.toHexString(difference)); } else { result.append(", -").append(Long.toHexString(-difference)); } last = currentAddress; } return result.toString(); }
Это позволило мне знать сразу искомые адреса и смещения до дополнительных объектов (просто чтобы сверять результат).
Нашел и съел
Нет ничего сложного в поиске. Понятно, что найдя адрес один раз (а мне его искать не пришлось, я его задебажил сам), мы можем сразу предпринять меры по выявлению сигнатуры. И я не идиот! Все подводные камни я предвкушал. Мне удалось подобрать подходящую сигнатуру, которая раз за разом находила правильный адрес моего игрока в памяти.
Сигнатуры я представлял как вектора из опциональных беззнаковых восьмибитных чисел
pub static ref PLAYER_INSTANCE_SIGNATURE: Mutex<Vec<Option<u8>>> = Mutex::new(vec![ Some(0x09), Some(0x00), Some(0x00), Some(0x00), Some(0x00), Some(0x00), Some(0x00), Some(0x00), None, None, Some(0x9B), Some(0x00), None, Some(0x00), Some(0x00), Some(0x00), None, None, None, None, None, None, None, Some(0xC0) ]);
Опциональные потому что в расте не оказалось чего-то, что напоминало бы 0x??, поэтому пустой опционал считался за любое вхождение.
Но как найти позицию? Да по сути можно также, используя сигнатуру, но есть нюанс…
Когда ты ищешь объект, представляющий игрока, который зачастую один (главный персонаж например), то подобрать сигнатуру под него легко. А что, если ты ищешь вектор? Векторов в приложении может быть много. Все они в динамической куче, если в векторе никаких ссылок на владельца нет, то она никак не отличается от других. Просто три u64 числа подряд (u64 потому что он максимально приближен к double в java). И как быть?
Это не единственный нюанс
Мы же все помним, что кофемашина имеет GC? Который может очищать память там… А еще может смещать данные в куче с целью оптимизации, устранения фрагментации, сжатии пустых мест и бла бла бла бла….
Так а что после таких мувментов нам ждать? А ждать того, что мы все наши прекрасные и превосходные лучшие найденные через боль и страдания путем перебора всего на свете адреса просто теряют актуальность.
Надо искать источник внутри!
Ну как-то же джава сама в курсе, где че там плавает, да? Мы же когда пишем код на ней, не теряем данные после очисток GC, ага?
Ну так давайте просто разберемся, откуда эта шайтан-машина тянет указатели на данные и будем делать также, вычисляя лишь глявный (root, instance или как угодно) объект, ведь да?
Давайте разбираться. Мы знаем из теории строения ООП объектов, что любой его член можно посчитать путем нахождения оффсета (смещения, если на языке Достоевского). Оффсет мы можем посчитать через инструменты внутри jdk, как его искать без них — этот вопрос не в мою смену, пожалуйста, сейчас мы ищем данные поля.
Делаем что-то вроде
unsafe.objectFieldOffset(field);
Чтобы учитывать наследование, рисуем примерно это
public static long calculateFieldOffset(Class<?> clazz, String fieldName) throws Exception { Unsafe unsafe = getUnsafe(); while (clazz != null) { try { Field field = clazz.getDeclaredField(fieldName); return unsafe.objectFieldOffset(field); } catch (NoSuchFieldException e) { clazz = clazz.getSuperclass(); } } throw new NoSuchFieldException("Поле " + fieldName + " не найдено в классе " + clazz.getName());
Ну и теперь мы знаем смещение.
Теперь справедливо было бы подумать, что добавив этот оффсет к адресу рута и дело в шляпе, указатель у нас. Ага, да, конечно…
Представим
Нам дан адрес рута (игрока), пусть 0x621830180
Оффсет посчитанный пусть будет 12. Тогда путем нехитрых математических рассчетов, мы получаем адрес поля — 0x62183018C. Бежим скорее по нему, чтобы забрать заслуженный указатель…
И получаем это:
BF 99 5E C4 E4 96 5E C4 E8 96 5E C4 01 00 00 00 00 00 00 00 F0 E9 00 00 00 00 00 00 00 00 00 00 8A 12 5B C4 01 00 00 00 00 00 00 00 38 67
При попытке сериализировать это дело в какой либо указатель, ничего хорошего не получаем. Ну вот я попытался прочитать это usize и получил адрес 0xC45E9691C45E9968, вы бы стали даже проверять его на верность?
Пошел смотреть, как это делает кофемашина
Летим в Unsafe, где есть великий и могучий
Object getObject(Object o, long offset)
Он делает то, что пытался я, но внутри процесса.
Видим, что он дергает
theInternalUnsafe.getReference(o, offset)
Летим по нему и ожидаемо он нативный.
public native Object getReference(Object o, long offset);
Окей, качаем сурс openjdk, подсказка: нам нужен unsafe.cpp
Тут мы встречаем следующую реализацию:
UNSAFE_ENTRY(jobject, Unsafe_GetReference(JNIEnv *env, jobject unsafe, jobject obj, jlong offset)) { oop p = JNIHandles::resolve(obj); assert_field_offset_sane(p, offset); oop v = HeapAccess<ON_UNKNOWN_OOP_REF>::oop_load_at(p, offset); return JNIHandles::make_local(THREAD, v); } UNSAFE_END
По сути, мы можем уже тут сворачиваться, ибо видим, что начинают создаваться различные обертки. А прелюдия в этом моменте намекают на конскую сложность в дальнейшем. Но мы же сюда пришли не для того, чтобы уйти, ведь так?
Ради интереса заглядываем в assert обработку, чтобы глянуть, что он проверяет перед чтением
static inline void assert_field_offset_sane(oop p, jlong field_offset) { #ifdef ASSERT jlong byte_offset = field_offset_to_byte_offset(field_offset); if (p != nullptr) { assert(byte_offset >= 0 && byte_offset <= (jlong)MAX_OBJECT_SIZE, "sane offset"); if (byte_offset == (jint)byte_offset) { void* ptr_plus_disp = cast_from_oop<address>(p) + byte_offset; assert(p->field_addr<void>((jint)byte_offset) == ptr_plus_disp, "raw [ptr+disp] must be consistent with oop::field_addr"); } jlong p_size = HeapWordSize * (jlong)(p->size()); assert(byte_offset < p_size, "Unsafe access: offset " INT64_FORMAT " > object's size " INT64_FORMAT, (int64_t)byte_offset, (int64_t)p_size); } #endif }
Тут ничего интересного, кроме одной маааааааленькой детали
jlong byte_offset = field_offset_to_byte_offset(field_offset);
А король-то голый?
Вы намекаете, что полученный оффсет вообще ненастоящий? В чем вернулось число? В фантиках? В ирисках?
Срочно летим к функции и понимаем, что это такой годный рофл
jlong field_offset_to_byte_offset(jlong field_offset) { return field_offset; }
Ок, вернемся к чтению. Пропускаем суету, натыкаемся на
HeapAccess<ON_UNKNOWN_OOP_REF>::oop_load_at(p, offset);
Окееей. Ещё одна прелюдия.
Не буду томить, там еще пара слоев
В конце концов мы придем сюда:
template <DecoratorSet decorators, typename P, typename T> inline T load(P* addr) { verify_types<decorators, T>(); using DecayedP = std::decay_t<P>; using DecayedT = std::conditional_t<HasDecorator<decorators, INTERNAL_VALUE_IS_OOP>::value, typename OopOrNarrowOop<T>::type, std::decay_t<T>>; // If a volatile address is passed in but no memory ordering decorator, // set the memory ordering to MO_RELAXED by default. const DecoratorSet expanded_decorators = DecoratorFixup< (std::is_volatile<P>::value && !HasDecorator<decorators, MO_DECORATOR_MASK>::value) ? (MO_RELAXED | decorators) : decorators>::value; return load_reduce_types<expanded_decorators, DecayedT>(const_cast<DecayedP*>(addr)); }
И тут моя глупая самонадеянная голова понимает, что указатель там может быть буквально любой, на него влияет куча разной меты, настроек и т.п. Он может быть разных типов, может быть сжат, да вообще чё угодно.
Вывод
Возможно я просто глуп и многое не понимаю (да скорее всего), но сейчас я делаю вывод, что такие вещи лучше все равно совершать через центр (а именно jdk). Найдите способ взаимодействовать с его членами через него, а не мимо. Возможно для реализации моей задумки можно как-то получить JNI ENV из другого процесса без инъекции, но как я уже говорил — не в мою смену, пока что я эту задачу решать не хочу.
ссылка на оригинал статьи https://habr.com/ru/articles/857874/
Добавить комментарий