[Реверс-инжиниринг] Модифицируем Last Epoch — От dnSpy до Ghidra
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Last Epoch — это однопользовательская ARPG на Unity и C#. В игре присутствует система крафта — игрок находит модификаторы, которые затем применяет к экипировке. С каждым модификатором накапливается "нестабильность", которая увеличивает шанс поломки предмета
Я преследовал две цели:
- Убрать "поломку" предмета в результате применения модификаторов
- Не расходовать модификаторы при крафте
Вот так выглядит окно крафта в игре
Часть первая, где мы редактируем .NET код без регистрации и смс
Для начала я опишу процесс модификации старой версии игры (0.7.8)
После компиляции C# превращается в IL (Intermediate Language) код. IL-код напоминает ассемблер высокого уровня абстракции и замечательно декомпилируется. В Unity проектах IL-код игры как правило находится в <GameFolder>/Managed/Assembly-CSharp.dll
Для редактирования IL-кода мы будем использовать dnSpy — лучший из всех инструментов подобного рода для .NET, что я встречал. dnSpy делает работу со скомпилированными .NET приложениями настолько легкой, что ее можно спутать с разработкой в обычной IDE
Ищем логику крафта
Итак, запускаем dnSpy и открываем Assembly-CSharp.dll
Мы сразу видим невероятно длинный список классов. В данном случае целью является модификация системы крафта, поэтому поступаю просто — ищу классы, в названии которых встречается Craft и просматриваю их логику по диагонали
После непродолжительных поисков мы нашли искомое — класс CraftingSlotManager
А именно метод Forge() в данном классе
Полная версия кода метода для желающих
SPL
// CraftingSlotManager
// Token: 0x06002552 RID: 9554 RVA: 0x0015E958 File Offset: 0x0015CB58
public void Forge()
{
if (!this.forging)
{
this.forging = true;
base.StartCoroutine(this.ForgeBlocker(10));
bool flag = false;
int num = -1;
if (this.main.HasContent())
{
int num2 = 0;
int num3 = 0;
if (this.debugNoFracture)
{
num3 = -10;
}
float num4 = 1f;
int num5 = -1;
bool flag2 = false;
ItemData data = this.main.GetContent()[0].data;
ItemData itemData = null;
if (this.support.HasContent())
{
itemData = this.support.GetContent()[0].data;
num5 = (int)itemData.subType;
if (itemData.subType == 0)
{
num3--;
flag2 = true;
}
else if (itemData.subType == 1)
{
num4 = UnityEngine.Random.Range(0.4f, 1f);
flag2 = true;
}
}
if (this.appliedAffixID >= 0)
{
Debug.Log("applied ID: " + this.appliedAffixID.ToString());
if (this.forgeButtonText.text == "Forge")
{
if (data.AddAffixTier(this.appliedAffixID, Mathf.RoundToInt((float)(5 + num2) * num4), num3))
{
num = this.appliedAffixID;
flag = true;
}
GlobalDataTracker.instance.CheckForShard(this.appliedAffixID);
if (flag2)
{
this.support.Clear();
}
if (!GlobalDataTracker.instance.CheckForShard(this.appliedAffixID))
{
this.DeselectAffixID();
}
}
}
else if (this.modifier.HasContent())
{
Debug.Log("modifier lets go");
ItemData data2 = this.modifier.GetContent()[0].data;
if (data2.itemType == 102)
{
if (data2.subType == 0)
{
Debug.Log("shatter it");
Notifications.CraftingOutcome(data.Shatter());
if (num5 == 0)
{
flag2 = false;
}
this.main.Clear();
flag = true;
this.ResetAffixList();
}
else if (data2.subType == 1)
{
Debug.Log("refine it");
if (data.AddInstability(Mathf.RoundToInt((float)(2 + num2) * num4), num3, 0))
{
data.ReRollAffixRolls();
}
flag = true;
}
else if (data2.subType == 2 && data.affixes.Count > 0)
{
Debug.Log("remove it");
if (data.AddInstability(Mathf.RoundToInt((float)(2 + num2) * num4), num3, 0))
{
ItemAffix affixToRemove = data.affixes[UnityEngine.Random.Range(0, data.affixes.Count)];
data.RemoveAffix(affixToRemove);
}
flag = true;
}
else if (data2.subType == 3 && data.affixes.Count > 0)
{
Debug.Log("cleanse it");
List<ItemAffix> list = new List<ItemAffix>();
foreach (ItemAffix item in data.affixes)
{
list.Add(item);
}
foreach (ItemAffix affixToRemove2 in list)
{
data.RemoveAffix(affixToRemove2);
}
if (num5 == 0)
{
flag2 = false;
}
data.SetInstability((int)((Mathf.Clamp(UnityEngine.Random.Range(5f, 15f), 0f, (float)data.instability) + (float)num2) * num4));
flag = true;
}
else if (data2.subType == 4 && data.sockets == 0)
{
Debug.Log("socket it");
data.AddSocket(1);
data.SetInstability((int)((Mathf.Clamp(UnityEngine.Random.Range(5f, 15f), 0f, (float)data.instability) + (float)num2) * num4));
flag = true;
}
}
}
if (flag)
{
UISounds.playSound(UISounds.UISoundLabel.CraftingSuccess);
if (this.modifier.HasContent())
{
ItemData data3 = this.modifier.GetContent()[0].data;
this.modifier.Clear();
if (num >= 0 && GlobalDataTracker.instance.CheckForShard(num))
{
this.PopShardToModifierSlot(num);
}
else if (data3.itemType == 102)
{
foreach (SingleSubTypeContainer singleSubTypeContainer in ItemContainersManager.instance.materials.Containers)
{
if (singleSubTypeContainer.CanAddItemType((int)data3.itemType) && singleSubTypeContainer.allowedSubID == (int)data3.subType && singleSubTypeContainer.HasContent())
{
singleSubTypeContainer.MoveItemTo(singleSubTypeContainer.GetContent()[0], 1, this.modifier, new IntVector2?(IntVector2.Zero), Context.SILENT);
break;
}
}
}
if (num >= 0 && this.prefixTierVFXObjects.Length != 0 && this.suffixTierVFXObjects.Length != 0)
{
ItemData itemData2 = null;
if (this.main.HasContent())
{
itemData2 = this.main.GetContent()[0].data;
}
if (itemData2 != null && this.main.HasContent())
{
List<ItemAffix> list2 = new List<ItemAffix>();
List<ItemAffix> list3 = new List<ItemAffix>();
foreach (ItemAffix itemAffix in itemData2.affixes)
{
if (itemAffix.affixType == AffixList.AffixType.PREFIX)
{
list2.Add(itemAffix);
}
else
{
list3.Add(itemAffix);
}
}
for (int i = 0; i < list2.Count; i++)
{
if ((int)list2[i].affixId == num && this.prefixTierVFXObjects[i])
{
this.prefixTierVFXObjects[i].SetActive(true);
}
}
for (int j = 0; j < list3.Count; j++)
{
if ((int)list3[j].affixId == num && this.suffixTierVFXObjects[j])
{
this.suffixTierVFXObjects[j].SetActive(true);
}
}
}
}
}
if (!flag2)
{
goto IL_6B3;
}
this.support.Clear();
using (List<SingleSubTypeContainer>.Enumerator enumerator2 = ItemContainersManager.instance.materials.Containers.GetEnumerator())
{
while (enumerator2.MoveNext())
{
SingleSubTypeContainer singleSubTypeContainer2 = enumerator2.Current;
if (singleSubTypeContainer2.CanAddItemType((int)itemData.itemType) && singleSubTypeContainer2.allowedSubID == (int)itemData.subType && singleSubTypeContainer2.HasContent())
{
singleSubTypeContainer2.MoveItemTo(singleSubTypeContainer2.GetContent()[0], 1, this.support, new IntVector2?(IntVector2.Zero), Context.SILENT);
break;
}
}
goto IL_6B3;
}
}
this.modifier.Clear();
this.support.Clear();
}
IL_6B3:
if (!flag)
{
UISounds.playSound(UISounds.UISoundLabel.CraftingFailure);
}
this.slamVFX.SetActive(true);
this.UpdateItemInfo();
this.UpdateFractureChanceDisplay();
this.UpdateForgeButton();
ShardCountText.UpdateAll();
}
}
С моей точки зрения данный код выглядит не так уж и плохо. Даже и не скажешь сразу, что это декомпелированная версия (выдают только названия переменных в духе num1, num2...). Есть даже логирование, которое позволяет легче понять назначение веток
После изучения кода я выяснил примерный механизм работы — у нас есть несколько CraftingSlot'ов, в которые мы помещаем предмет крафта и модификаторы. CraftingSlotManager соответсвенно управляет взаимодействием этих слотов и в целом отвечает за логику крафта
Отключаем расходование ресурсов при крафте
Нас интересуют две переменные: this.modifier и this.support
Это слоты для модификаторов, которые используются во время крафта
Как оказалось уничтожение модификаторов происходит в процессе следующих вызовов:
this.modifier.Clear();
this.support.Clear();
Хотя для меня не очевидно, почему очистка слота обязательно должна удалять его содержимое (а не, например, возвращать обратно в инвентарь) — это вопрос наименования методов и переменных и к нашему исследованию отношения не имеет
Удаляем все вызовы this.modifier.Clear(); и this.support.Clear(); из кода функции и радуемся
Процесс редактирования в dnSpy просто фантастика — просто поправили код и сохранили в .dll — все изменения будут скомпилированы автоматически
Убираем поломку предмета в процессе крафта
В игре поломка предмета в процессе крафта называется Fracture, поэтому мне сразу бросился в глаза данный кусок кода
И действительно — модификация вида int num3 = -10; полностью отключает поломку — спасибо разработчикам за оставленный дебаг флаг :)
Часть вторая, где мы испытываем боль и страдания
Начиная с версии 0.7.9 разработчики начали использовать IL2CPP для того, чтобы собирать проект напрямую в нативный бинарник под нужную платформу. В результате у нас больше нет IL-кода, а есть лишь хардкорный ассемблер… но кого это останавливало?
Дисклеймер
SPL
Автор статьи не является реверс-инженером, а имеет за спиной только остатки университетского курса Ассемблера и один No-CD, сделанный в OllyDbg 10 лет назад. Вполне возможно, что какие-то вещи были сделаны неправильно или их можно было сделать намного лучше и проще
Ищем иголку в стоге Гидры
Итак, из папки игры пропали все старые .dll-ки и мы взамен получили один огромный GameAssembly.dll весом в 55 мегабайт. Наши цели не изменились, но теперь все будет намного сложнее.
Первым делом загружаем dll-ку в Ghidra'у и соглашаемся на все виды анализа, которые она предлагает (анализ занимает довольно много времени и в дальнейшем я останавливал его на стадии Analyze Address Table)
На этом я, как начинающий реверсер, и закончил бы, ведь каким образом тут искать нужный нам код — совершенно мне непонятно
К счастью погуглив на тему IL2CPP я нашел утилиту Il2CppDumper, которая позволяет частично восстановить информацию на основе метадата-файла (который обнаружился по пути <GameFolder>/il2cpp_data/Metadata/global-metadata.dat). Не знаю является ли данный файл необходимостью или разработчики просто забыли убрать его, но он сильно облегчил нашу задачу
Скармливаем утилите наши файлы dll и метадаты и получаем набор восстановленных данных
В папке DummyDll находятся восстановленные dll-ки с частично восстановленным IL-кодом. Загружаем восстановленный Assembly-CSharp.dll в dnSpy и идем в наш любимый CraftingSlotManager
Ну что же, кода у нас больше нет, зато у нас есть адрес! В аннотации
Address(RVA = "0x5B9FC0", Offset = "0x5B89C0", VA = "0x1805B9FC0")
Нам нужно значение VA — это оффест, по которому мы найдем нашу функцию в Гидре
Теперь мы хотя бы нашли начало нашей функции, что уже неплохо
Можно ли сделать лучше? Вспоминаем, что Il2CppDumper генерирует данные, котрые можно импортировать в Гидру — копируем скрипт в папку скриптов Гидры, запускаем ghidra.py и скармливаем ему script.json, сгенерированный из нашей метадаты. Теперь у всех функций, которые были объявлены в исходном коде, появились имена
Отключаем расходование ресурсов при крафте
Мы уже знаем, что нам достаточно убрать вызовы this.modifier.Clear(); и this.support.Clear();. Осталось найти их в коде. К счастью восстановленные имена функций помогают решить эту задачу довольно просто
Ломать — не строить. Чтобы убрать вызов функции нам достаточно заменить все байты, участвующие в CALL на NOP
Разбиваем команду на отдельные байты (выделив ее и нажав C, или Clear Code Bytes), затем в бинарном представлении просто впечатываем 90 пять раз. Готово!
Такую операцию повторяем для всех вызвов OneSlotItemContainer$$Clear() из нашей функции Forge() (На самом деле это нужно делать не для всех вызовов, потому что в коде есть один вызов this.main.Clear(); Но мне было слишком лениво выискивать конкретное исключение в ассемблерной каше, поэтому я убрал все вызовы)
Убираем поломку предмета в процессе крафта
Изначально мы делали int num3 = -10; и благодарили разработчика за оставленный дебаг флаг в качестве подсказки. Теперь это не кажется такой простой задачей — сложно понять, какая из ~60 локальных переменных, найденных Гидрой, является нужной. После 15 минут поиска мне наконец удалось понять, что зубодробительная строчка на скриншоте ниже является той самой проверкой дебаг флага и вычитанием из переменной
К сожалению моих знаний Ассемблера не хватило, чтобы понять как именно это работает (судя по Гидре этот процесс занимает 4 команды начиная с MOVZX и заканчивая на AND), поэтому деликатно изменить эту часть я не смог. Другого способа изменить эту переменную я тоже не нашел в силу своих ограниченных знаний, поэтому я изменил подход
Посмотрев еще раз в замечательный (после работы с Гидрой) код старой версии игры в dnSpy я увидел, что за накопление "нестабильности" отвечает метод AddInstability
public bool AddInstability(int addedInstability, int fractureTierModifier = 0, int affixTier = 0)
{
int num = this.RollFractureTier(fractureTierModifier, affixTier);
if (num > 0)
{
this.Fracture(num); // <----- Предмет ломается тут
return false;
}
this.instability = ((int)this.instability + addedInstability).clampToByte();
this.RebuildID();
return true;
}
Гидра радует нас относитлеьно простым кодом данной функции
По коду мы видим, что сначала происходит вызов CALL ItemData$$RollFractureTier, затем мы проверяем результат TEST EAX и прыгаем в нужную ветку
Нам нужно, чтобы мы всегда шли по ветке uVar3 < 1. Тут можно сделать разные исправления — например (могу ошибаться) поменять JG(Jump short if greater) на JLE(Jump short if less or equal)
Я решил вопрос иначе — просто сделаем проверяемый регистр равным нулю и тогда остальной код будет работать как надо. Меняем CALL на XOR EAX, EAX (самый просто способ обнулить регистр в Ассемблере), который занимает два байта и оставшиеся три байта заполняем NOP'ами
Готово! Сохраняем, заменяем существующий GameAssembly.dll (почему-то Гидра в процессе экспорта файла всегда добавляет расширение .bin и его нужно удалять) и чувствуем себя хакерами
Выводы
Для многих компиляция является "путем в один конец", а бинарные файлы представляются неуязвимыми бастионами, которые способны пасть только перед лучшими хакерами
В реальности многие популярные языки компилируются в примежуточный код, который замечательно трактуется и модифицируется профильными декомпиляторами. Для подобных модификаций зачастую хватит обычного умения программировать хоть на чем-нибудь
И, хотя нативные бинарники и могут представлять опасность для ваших глаз и мозга, повехрностных знаний о том, как работают программы на уровне, близком к железу, зачастую бывает достаточно в связке с современными open-source инструментами для небольших модификаций
===========
Источник:
habr.com
===========
Похожие новости:
- [Разработка игр, C#, Unity] Управление сценами в Unity без боли и страданий
- [Информационная безопасность, C, Реверс-инжиниринг, CTF] Работаем с Cutter — основы реверса. Решение задач на реверсинг с r0от-мi. Часть 3
- [Open source, Реверс-инжиниринг] ReactOS 0.4.13 CE (Coronavirus Edition)
- [Информационная безопасность, Реверс-инжиниринг] Руткиты на основе BIOS. Часть 1 (перевод)
- [Производство и разработка электроники, Процессоры, Реверс-инжиниринг] Реверс-инжиниринг чипа компьютера Commodore (перевод)
- [Unity, Работа с 3D-графикой, Работа с видео, Разработка игр] Создание панорамных видео в Unity (перевод)
- [Реверс-инжиниринг, Умный дом] Выгружаем данные из метеорологической стации Oregon Scientific WMR500
- [C, Компьютерная анимация, Программирование, Реверс-инжиниринг] Разжимаем древний формат сжатия анимаций
- [Восстановление данных, Реверс-инжиниринг, Старое железо] Как я восстанавливал данные в неизвестном формате с магнитной ленты
- [Firefox, JavaScript, Python, Реверс-инжиниринг, Системы обмена сообщениями] Магия WebPush в Mozilla Firefox. Взгляд изнутри
Теги для поиска: #_reversinzhiniring (Реверс-инжиниринг), #_reverse_engineering, #_unity3d, #_ghidra, #_dnspy, #_reversinzhiniring (
Реверс-инжиниринг
)
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 23:47
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Last Epoch — это однопользовательская ARPG на Unity и C#. В игре присутствует система крафта — игрок находит модификаторы, которые затем применяет к экипировке. С каждым модификатором накапливается "нестабильность", которая увеличивает шанс поломки предмета Я преследовал две цели:
Вот так выглядит окно крафта в игре Часть первая, где мы редактируем .NET код без регистрации и смс Для начала я опишу процесс модификации старой версии игры (0.7.8) После компиляции C# превращается в IL (Intermediate Language) код. IL-код напоминает ассемблер высокого уровня абстракции и замечательно декомпилируется. В Unity проектах IL-код игры как правило находится в <GameFolder>/Managed/Assembly-CSharp.dll Для редактирования IL-кода мы будем использовать dnSpy — лучший из всех инструментов подобного рода для .NET, что я встречал. dnSpy делает работу со скомпилированными .NET приложениями настолько легкой, что ее можно спутать с разработкой в обычной IDE Ищем логику крафта Итак, запускаем dnSpy и открываем Assembly-CSharp.dll Мы сразу видим невероятно длинный список классов. В данном случае целью является модификация системы крафта, поэтому поступаю просто — ищу классы, в названии которых встречается Craft и просматриваю их логику по диагонали После непродолжительных поисков мы нашли искомое — класс CraftingSlotManager А именно метод Forge() в данном классе Полная версия кода метода для желающихSPL// CraftingSlotManager
// Token: 0x06002552 RID: 9554 RVA: 0x0015E958 File Offset: 0x0015CB58 public void Forge() { if (!this.forging) { this.forging = true; base.StartCoroutine(this.ForgeBlocker(10)); bool flag = false; int num = -1; if (this.main.HasContent()) { int num2 = 0; int num3 = 0; if (this.debugNoFracture) { num3 = -10; } float num4 = 1f; int num5 = -1; bool flag2 = false; ItemData data = this.main.GetContent()[0].data; ItemData itemData = null; if (this.support.HasContent()) { itemData = this.support.GetContent()[0].data; num5 = (int)itemData.subType; if (itemData.subType == 0) { num3--; flag2 = true; } else if (itemData.subType == 1) { num4 = UnityEngine.Random.Range(0.4f, 1f); flag2 = true; } } if (this.appliedAffixID >= 0) { Debug.Log("applied ID: " + this.appliedAffixID.ToString()); if (this.forgeButtonText.text == "Forge") { if (data.AddAffixTier(this.appliedAffixID, Mathf.RoundToInt((float)(5 + num2) * num4), num3)) { num = this.appliedAffixID; flag = true; } GlobalDataTracker.instance.CheckForShard(this.appliedAffixID); if (flag2) { this.support.Clear(); } if (!GlobalDataTracker.instance.CheckForShard(this.appliedAffixID)) { this.DeselectAffixID(); } } } else if (this.modifier.HasContent()) { Debug.Log("modifier lets go"); ItemData data2 = this.modifier.GetContent()[0].data; if (data2.itemType == 102) { if (data2.subType == 0) { Debug.Log("shatter it"); Notifications.CraftingOutcome(data.Shatter()); if (num5 == 0) { flag2 = false; } this.main.Clear(); flag = true; this.ResetAffixList(); } else if (data2.subType == 1) { Debug.Log("refine it"); if (data.AddInstability(Mathf.RoundToInt((float)(2 + num2) * num4), num3, 0)) { data.ReRollAffixRolls(); } flag = true; } else if (data2.subType == 2 && data.affixes.Count > 0) { Debug.Log("remove it"); if (data.AddInstability(Mathf.RoundToInt((float)(2 + num2) * num4), num3, 0)) { ItemAffix affixToRemove = data.affixes[UnityEngine.Random.Range(0, data.affixes.Count)]; data.RemoveAffix(affixToRemove); } flag = true; } else if (data2.subType == 3 && data.affixes.Count > 0) { Debug.Log("cleanse it"); List<ItemAffix> list = new List<ItemAffix>(); foreach (ItemAffix item in data.affixes) { list.Add(item); } foreach (ItemAffix affixToRemove2 in list) { data.RemoveAffix(affixToRemove2); } if (num5 == 0) { flag2 = false; } data.SetInstability((int)((Mathf.Clamp(UnityEngine.Random.Range(5f, 15f), 0f, (float)data.instability) + (float)num2) * num4)); flag = true; } else if (data2.subType == 4 && data.sockets == 0) { Debug.Log("socket it"); data.AddSocket(1); data.SetInstability((int)((Mathf.Clamp(UnityEngine.Random.Range(5f, 15f), 0f, (float)data.instability) + (float)num2) * num4)); flag = true; } } } if (flag) { UISounds.playSound(UISounds.UISoundLabel.CraftingSuccess); if (this.modifier.HasContent()) { ItemData data3 = this.modifier.GetContent()[0].data; this.modifier.Clear(); if (num >= 0 && GlobalDataTracker.instance.CheckForShard(num)) { this.PopShardToModifierSlot(num); } else if (data3.itemType == 102) { foreach (SingleSubTypeContainer singleSubTypeContainer in ItemContainersManager.instance.materials.Containers) { if (singleSubTypeContainer.CanAddItemType((int)data3.itemType) && singleSubTypeContainer.allowedSubID == (int)data3.subType && singleSubTypeContainer.HasContent()) { singleSubTypeContainer.MoveItemTo(singleSubTypeContainer.GetContent()[0], 1, this.modifier, new IntVector2?(IntVector2.Zero), Context.SILENT); break; } } } if (num >= 0 && this.prefixTierVFXObjects.Length != 0 && this.suffixTierVFXObjects.Length != 0) { ItemData itemData2 = null; if (this.main.HasContent()) { itemData2 = this.main.GetContent()[0].data; } if (itemData2 != null && this.main.HasContent()) { List<ItemAffix> list2 = new List<ItemAffix>(); List<ItemAffix> list3 = new List<ItemAffix>(); foreach (ItemAffix itemAffix in itemData2.affixes) { if (itemAffix.affixType == AffixList.AffixType.PREFIX) { list2.Add(itemAffix); } else { list3.Add(itemAffix); } } for (int i = 0; i < list2.Count; i++) { if ((int)list2[i].affixId == num && this.prefixTierVFXObjects[i]) { this.prefixTierVFXObjects[i].SetActive(true); } } for (int j = 0; j < list3.Count; j++) { if ((int)list3[j].affixId == num && this.suffixTierVFXObjects[j]) { this.suffixTierVFXObjects[j].SetActive(true); } } } } } if (!flag2) { goto IL_6B3; } this.support.Clear(); using (List<SingleSubTypeContainer>.Enumerator enumerator2 = ItemContainersManager.instance.materials.Containers.GetEnumerator()) { while (enumerator2.MoveNext()) { SingleSubTypeContainer singleSubTypeContainer2 = enumerator2.Current; if (singleSubTypeContainer2.CanAddItemType((int)itemData.itemType) && singleSubTypeContainer2.allowedSubID == (int)itemData.subType && singleSubTypeContainer2.HasContent()) { singleSubTypeContainer2.MoveItemTo(singleSubTypeContainer2.GetContent()[0], 1, this.support, new IntVector2?(IntVector2.Zero), Context.SILENT); break; } } goto IL_6B3; } } this.modifier.Clear(); this.support.Clear(); } IL_6B3: if (!flag) { UISounds.playSound(UISounds.UISoundLabel.CraftingFailure); } this.slamVFX.SetActive(true); this.UpdateItemInfo(); this.UpdateFractureChanceDisplay(); this.UpdateForgeButton(); ShardCountText.UpdateAll(); } } С моей точки зрения данный код выглядит не так уж и плохо. Даже и не скажешь сразу, что это декомпелированная версия (выдают только названия переменных в духе num1, num2...). Есть даже логирование, которое позволяет легче понять назначение веток После изучения кода я выяснил примерный механизм работы — у нас есть несколько CraftingSlot'ов, в которые мы помещаем предмет крафта и модификаторы. CraftingSlotManager соответсвенно управляет взаимодействием этих слотов и в целом отвечает за логику крафта Отключаем расходование ресурсов при крафте Нас интересуют две переменные: this.modifier и this.support Это слоты для модификаторов, которые используются во время крафта Как оказалось уничтожение модификаторов происходит в процессе следующих вызовов: this.modifier.Clear();
this.support.Clear(); Хотя для меня не очевидно, почему очистка слота обязательно должна удалять его содержимое (а не, например, возвращать обратно в инвентарь) — это вопрос наименования методов и переменных и к нашему исследованию отношения не имеет Удаляем все вызовы this.modifier.Clear(); и this.support.Clear(); из кода функции и радуемся Процесс редактирования в dnSpy просто фантастика — просто поправили код и сохранили в .dll — все изменения будут скомпилированы автоматически Убираем поломку предмета в процессе крафта В игре поломка предмета в процессе крафта называется Fracture, поэтому мне сразу бросился в глаза данный кусок кода И действительно — модификация вида int num3 = -10; полностью отключает поломку — спасибо разработчикам за оставленный дебаг флаг :) Часть вторая, где мы испытываем боль и страдания Начиная с версии 0.7.9 разработчики начали использовать IL2CPP для того, чтобы собирать проект напрямую в нативный бинарник под нужную платформу. В результате у нас больше нет IL-кода, а есть лишь хардкорный ассемблер… но кого это останавливало? ДисклеймерSPLАвтор статьи не является реверс-инженером, а имеет за спиной только остатки университетского курса Ассемблера и один No-CD, сделанный в OllyDbg 10 лет назад. Вполне возможно, что какие-то вещи были сделаны неправильно или их можно было сделать намного лучше и проще
Ищем иголку в стоге Гидры Итак, из папки игры пропали все старые .dll-ки и мы взамен получили один огромный GameAssembly.dll весом в 55 мегабайт. Наши цели не изменились, но теперь все будет намного сложнее. Первым делом загружаем dll-ку в Ghidra'у и соглашаемся на все виды анализа, которые она предлагает (анализ занимает довольно много времени и в дальнейшем я останавливал его на стадии Analyze Address Table) На этом я, как начинающий реверсер, и закончил бы, ведь каким образом тут искать нужный нам код — совершенно мне непонятно К счастью погуглив на тему IL2CPP я нашел утилиту Il2CppDumper, которая позволяет частично восстановить информацию на основе метадата-файла (который обнаружился по пути <GameFolder>/il2cpp_data/Metadata/global-metadata.dat). Не знаю является ли данный файл необходимостью или разработчики просто забыли убрать его, но он сильно облегчил нашу задачу Скармливаем утилите наши файлы dll и метадаты и получаем набор восстановленных данных В папке DummyDll находятся восстановленные dll-ки с частично восстановленным IL-кодом. Загружаем восстановленный Assembly-CSharp.dll в dnSpy и идем в наш любимый CraftingSlotManager Ну что же, кода у нас больше нет, зато у нас есть адрес! В аннотации Address(RVA = "0x5B9FC0", Offset = "0x5B89C0", VA = "0x1805B9FC0")
Нам нужно значение VA — это оффест, по которому мы найдем нашу функцию в Гидре Теперь мы хотя бы нашли начало нашей функции, что уже неплохо Можно ли сделать лучше? Вспоминаем, что Il2CppDumper генерирует данные, котрые можно импортировать в Гидру — копируем скрипт в папку скриптов Гидры, запускаем ghidra.py и скармливаем ему script.json, сгенерированный из нашей метадаты. Теперь у всех функций, которые были объявлены в исходном коде, появились имена Отключаем расходование ресурсов при крафте Мы уже знаем, что нам достаточно убрать вызовы this.modifier.Clear(); и this.support.Clear();. Осталось найти их в коде. К счастью восстановленные имена функций помогают решить эту задачу довольно просто Ломать — не строить. Чтобы убрать вызов функции нам достаточно заменить все байты, участвующие в CALL на NOP Разбиваем команду на отдельные байты (выделив ее и нажав C, или Clear Code Bytes), затем в бинарном представлении просто впечатываем 90 пять раз. Готово! Такую операцию повторяем для всех вызвов OneSlotItemContainer$$Clear() из нашей функции Forge() (На самом деле это нужно делать не для всех вызовов, потому что в коде есть один вызов this.main.Clear(); Но мне было слишком лениво выискивать конкретное исключение в ассемблерной каше, поэтому я убрал все вызовы) Убираем поломку предмета в процессе крафта Изначально мы делали int num3 = -10; и благодарили разработчика за оставленный дебаг флаг в качестве подсказки. Теперь это не кажется такой простой задачей — сложно понять, какая из ~60 локальных переменных, найденных Гидрой, является нужной. После 15 минут поиска мне наконец удалось понять, что зубодробительная строчка на скриншоте ниже является той самой проверкой дебаг флага и вычитанием из переменной К сожалению моих знаний Ассемблера не хватило, чтобы понять как именно это работает (судя по Гидре этот процесс занимает 4 команды начиная с MOVZX и заканчивая на AND), поэтому деликатно изменить эту часть я не смог. Другого способа изменить эту переменную я тоже не нашел в силу своих ограниченных знаний, поэтому я изменил подход Посмотрев еще раз в замечательный (после работы с Гидрой) код старой версии игры в dnSpy я увидел, что за накопление "нестабильности" отвечает метод AddInstability public bool AddInstability(int addedInstability, int fractureTierModifier = 0, int affixTier = 0)
{ int num = this.RollFractureTier(fractureTierModifier, affixTier); if (num > 0) { this.Fracture(num); // <----- Предмет ломается тут return false; } this.instability = ((int)this.instability + addedInstability).clampToByte(); this.RebuildID(); return true; } Гидра радует нас относитлеьно простым кодом данной функции По коду мы видим, что сначала происходит вызов CALL ItemData$$RollFractureTier, затем мы проверяем результат TEST EAX и прыгаем в нужную ветку Нам нужно, чтобы мы всегда шли по ветке uVar3 < 1. Тут можно сделать разные исправления — например (могу ошибаться) поменять JG(Jump short if greater) на JLE(Jump short if less or equal) Я решил вопрос иначе — просто сделаем проверяемый регистр равным нулю и тогда остальной код будет работать как надо. Меняем CALL на XOR EAX, EAX (самый просто способ обнулить регистр в Ассемблере), который занимает два байта и оставшиеся три байта заполняем NOP'ами Готово! Сохраняем, заменяем существующий GameAssembly.dll (почему-то Гидра в процессе экспорта файла всегда добавляет расширение .bin и его нужно удалять) и чувствуем себя хакерами Выводы Для многих компиляция является "путем в один конец", а бинарные файлы представляются неуязвимыми бастионами, которые способны пасть только перед лучшими хакерами В реальности многие популярные языки компилируются в примежуточный код, который замечательно трактуется и модифицируется профильными декомпиляторами. Для подобных модификаций зачастую хватит обычного умения программировать хоть на чем-нибудь И, хотя нативные бинарники и могут представлять опасность для ваших глаз и мозга, повехрностных знаний о том, как работают программы на уровне, близком к железу, зачастую бывает достаточно в связке с современными open-source инструментами для небольших модификаций =========== Источник: habr.com =========== Похожие новости:
Реверс-инжиниринг ) |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 23:47
Часовой пояс: UTC + 5