- 16
- 12
Всем привет, читатели, в этой "статье" будет разбор проты либки в топ 2 по популярности КРМП Мобайл (Матрешка РП)
Впервые пишу статейку на бластхаке. Решил я чот подергать клиент Матрешки Мобайл (пакет com.matreshkarp.game), а точнее их либку lib/arm64-v8a/libmatreshka.so. Игруха на флаттере (там libflutter.so + libapp.so на 22 мега дартового кода), а вся сердцевина клиента спрятана вот в этом чуде. Название крмпшки будто придумывали под либку (хотя первая версия вышла в 21 году, а такая защитка примерно с 23),
Использовал IDA Pro 9.4, плюс по мелочи idapython и разок питон для энтропии, т.к. в иде ее из коробки нет. Ушло на все часов пять +-, потому что либка жирная и наглухо обфусцирована.
По сегментам:
Кидаю libmatreshka.so в иду, несколько минут жевала либку. Первый сюрприз от разрабов - 49 мегабайт веса либки. Для либки это многовато, уже попахивало что внутри что-то запаковано.
Смотрю сегменты смотреть как оно разложено, и вот тут первые непонятки:
Два сегмента LOAD, и оба исполняемые (r-x). Обычно .text один. А тут два куска кода, причём у второго другое выравнивание. сегмент с именем .__data1, оно нестандартное. Флаг X (executable), виртуалка 0x110000, а вот файловый оффсет 0x1850000, то есть в самом конце файла. Размер +- 23 мегабайта.
Прикол в том, что реальный андроидный линкер грузит по тем самым load, а ида и прочие тулы больше верят таблице секций. И вот .__data1 говорит дизассемблеру, чтобы код для адреса 0x110000 брало из хвоста либки. Чуть посравнял регион 0x110000 и хвост 0x1850000 совпадают один в один, ровно 0x1680000 байт, то есть в файле лежат две одинаковые копии образа кода. Вот тебе и +23 мегабайта к весу.
Зачем копия хз конечн, но по логике одну копию (ту что в LOAD) стаб дешифрует и правит прямо в памяти, а вторая в хвосте, нетронутый оригинал, чтоб было откуда перечитать / чем сверить целостность. Ну или просто исходный запакованный блоб.
Экспорты либки
С этим смирился, и полез в экспорты, и там мне тоже впихнули сюрприз:
Код:
FgljZF
EfuDv
TU_IRRKSbIHXfGKLaOde
STxSxXETASFdVJRLIbO
NOsNsSxzHxKEKE
... ещё пара тыщ таких
Имена рандомизированы, читать нечего. Но между этой шелухой торчат вменяемые JNI-функи:
Код:
JNI_OnLoad
Java_com_native_Main_load / preInit / appEvent / touchEvent / windowSize / setSinglePlayer / hasFlutter
Java_com_native_RenderQueue_Lock / Unlock
Java_com_native_libLoader_base / getSym / execEnd
Отдельно зацепил libLoader_getSym / base / execEnd. Это походу свой лоадер, ну или то есть либка умеет сама подтягивать и разворачивать какие-то куски.
Где ваще точка входа?
Логично начать с .init_array, ну и перешел туда, а оно из 141 нуля, хм.
Подумал сначала, что я не туда полез, дальше глянул .rela.dyn, и картина проясняется, почти все релокации это тупо R_AARCH64_NONE (штук 7000+), а нормальных RELATIVE всего т четыре. Для такой либки это чушь, если обычно там тысячи. Ну и вот одна из этих четырех как раз пишет в .init_array:
Код:
0x17b21a0 R_AARCH64_RELATIVE -> 0x101ca28
То есть на диске инит пустой, а живой поинтер дорисовывается релокацией уже в памяти. Один-единственный. И ведёт он на 0x101ca28, это начало второго (открытого) сегмента, точнее спрятанной точки входа. Прикольн, глазами в статике инит пустой, старта не видно.
Кста, про none релокации, это не просто так, раз настоящие релокации выпилены в none, значит привязку поинтеров стаб делает сам из своей спрятанной таблицы. Практический вывод на будущее, тупо снять дамп памяти и переиспользовать не выйдет, без ручного релокатора он развалится, дефолтный анти-дамп
Поломанный граф
Перехожу на 0x101CA28, пролог нормальный, регистры сохраняет, а дальше иду плющит, то есть граф не строится, сплошные красные стрелки и куски undefined. Ну и и видно за что цепляется:
Короче обфускация потока трамплинами. Схема всегда одна: adr reg берёт свой же адрес, add reg, #0x14, br reg, и прыжок ровно на +20, перескакивая два мертвых слова. А в этих словах либо левый бранч, либо редкая sve-хрень (uaddlb, ld1rqb), на которой дизасм рассыпается. И так по всей либке, не только в стабе я потом посчитал, таких add reg,#0x14; br reg под 800 штук.
Руками это читать нереально, поэтому накидал скриптик на idapython, чисто пройтись по трамплинам и вычислять реальную цель (adr запомнили базу, на add прибавили, на br прыгнули). После этого поток разворачивается в нормальную линию.
Python:
import idc, ida_ua
def follow(ea, limit=200):
reg = {}
seen = set()
while limit and ea not in seen:
limit -= 1; seen.add(ea)
mnem = idc.print_insn_mnem(ea)
print(hex(ea), idc.GetDisasm(ea))
if mnem in ("ADR", "ADRP"):
reg[idc.print_operand(ea,0)] = idc.get_operand_value(ea,1)
elif mnem == "ADD":
d, s = idc.print_operand(ea,0), idc.print_operand(ea,1)
if d == s and d in reg:
reg[d] += idc.get_operand_value(ea,2)
elif mnem == "BR":
r = idc.print_operand(ea,0)
if r in reg:
ea = reg[r]; continue
break
elif mnem == "B":
ea = idc.get_operand_value(ea,0); continue
elif mnem == "RET":
break
ea = idc.next_head(ea)
follow(0x101CA28)
Что в стабе вообще, черт возьми
Пошаговые действия стаба:
Ищет свои маркеры первым делом. Гоняет ldrb-ом по памяти и сверяет байты: сначала сигнатуру FF FF BE D9 (начало блока), потом C0 03 5F D6 (а это, если развернуть, ret конец блока):
Код:
LDRB W10, [X8], #1 ; CMP #0xFF
LDRB W10, [X8] ; CMP #0xFF
LDRB W10, [X9, #2] ; CMP #0xBE
LDRB W10, [X9, #3] ; CMP #0xD9 -> FF FF BE D9
Прикольно самому было найти маркеры, а так у меня нашлось всего 2 места. Значит патчит оно точечно, не весь код подряд.
Ну и затирает маркеры
Код:
MOV W11, #0x201F
MOVK W11, #0xD503, LSL #16 ; W11 = 0xD503201F == NOP
STP W11, W11, [X9] ; два nop поверх сигнатуры
Ну и дергает ядро мимо libc, то есть память оно готовит не через mmap/mprotect из libc, а сырыми svc:
Код:
MOV X8, #0xDE ; 222 = mmap
SVC #0
MOV X8, #0xE2 ; 226 = mprotect (RW чтоб записать расшифрованное, потом обратно R-X)
SVC #0
MOV X8, #0xD7 ; 215 = munmap
SVC #0
То есть, если кто повесил хук на libcшные mmap/mprotect (фрида, LD_PRELOAD и т.п.), он нихера не увидит, потому что вызов идет напрямую в ядро, ну а всего таких прямых сисколов я насчитал десяточку : mprotect - 4, mmap - 2, munmap - 2 и один gettimeofday (169), последний удобен как замер времени, палить отладчик/эмуль по тормозам.
Декодер инструкций и проверка себя, оно читает по 32-битному слову и делает сдвиги на 21/27/25, это ровно поля класса инструкции в arm64, прогоняет через simd и мешает с магией:
Код:
LDR W4, [X8] ; слово кода
DUP V17.4S, W4
LSR W23, W4, #0x15 ; >>21 \
LSR W24, W4, #0x1B ; >>27 > поля опкода
LSR W25, W4, #0x19 ; >>25 /
BFXIL W24, W4, #0x1E, #2 ; тасует поля
...
EOR X3, X3, X12 ; X12 = 0xDEADBEEF
ADD X3, X3, X13 ; X13 = 0x0BADCAFE
AND X3, X3, X14 ; X14 = 0x55555555
Тут расшифровка тела (см. ниже про энтропию). Во-вторых checksum, оно нормализует адресные поля у bl/adrp (они же плавают от релокаций) и сворачивает все в хэш, ну и пропатчил инструкцию = сумма не сойдётся, в простонародье антитампер, ну и константы 0xDEADBEEF / 0x0BADCAFE / 0x55555555, по ним, кстати, потом легко найти само сравнение хэша.
Что же с телом игры? (зашифровано)
Пошел искать обычные функции, типа, Java_com_native_Main_load (по экспортам это 0x10B56A4). Перехожу, а там вообще хз пойми что, иде дизассемблирить нечего, сплошные dcb/undefined и рандомные байты:
Код:
0x10B56A4: 6F 8A 66 F7 8B 04 B2 41 EA 03 5F D6 9F 09 EC 0E ...
Пошел в дамп хекса, листаю первый сегмент 0x110000..0xF00000, и это хз что вообще, ни одного осмысленного пролога, ничего, а у иды крыша едет, само собой.
В иде энтропии нет, поэтому быстро посчитал питоном по окошкам. Вышло:
Код:
первый сегмент (0x110000..0xF00000) H ≈ 7.9 .. 8.0 - рандом, то есть шифр
дыра (0xF00000..0x1000000) H = 0 - одни нули (место под bss)
второй сегмент (0x1000000..0x1791BD4) H ≈ 5.5 .. 7.4 <- нормальный код (загрузчик)
Ну, значит тут реально матрешка, если первый сегмент (+- 15 мегабайт) зашифрованное тело клиента, энтропия почти 8.0, нормальный код так не выглядит (у него 5.5-6.5). Сюда указывают все "настоящие" экспорты, но в статике они мертвые, а второй сегмент (+- 7.8 мегабайт) открытый (хоть и обфусцированный трамплинами) лоадер, вот его и его одного мы реально видим в иде: стаб 0x101CA28, JNI_OnLoad на 0x1412170 (декодер)
При dlopen порядок такой: линкер мапнул оба сегмента → сработал тот единственный инит 0x101CA28 - стаб своими сисколами дешифрует и релоцирует первый сегмент, попутно сверяя целостность → и уже живому телу отдаёт руль. Пока это не отработало, реверсить в первом сегменте нечего, там шум.
Что же еще наверху?
pac + bti, прологи функций собраны с поинтер-аутентификацией (ARMv8.3) и BTI (8.5). Ида в новых версиях их прям так и показывает PACIASP в начале и BTI C на целях переходов. Я прикинул по первым словам функций: PACIASP стоит у +-748 функций, BTI C у +-595. Против ROP и грубого перебивания переходов самое то, ну и наивный анализ прологов сбивает.
Строки зашифрованы, ни ptrace, ни TracerPid, ни frida, ни /proc/... в открытую нет, только огрызки, энтропия .rodata тоже +-8 , значит строки крутятся расшифрованными в рантайме, в статике их не почитать.
Импорты при этом сами по себе говорящие, даже если строки спрятаны:
Код:
syscall, mmap, mprotect (через svc), open, read, stat, access,
opendir / readdir / fdopendir шарится по /proc (TracerPid, maps)
getpid, gettid, pthread_self сверка потоков
sigaction, raise, strsignal сигнальные хендлеры (SIGTRAP/SIGSEGV):
setjmp, longjmp ловля брейков, восстановление потока
dl_iterate_phdr, dladdr сам себя находит, сверяет свои сегменты
(я не декриптал либку, а анализировал в ida pro)
Вложения
Последнее редактирование: