Вторая и финальная часть про протектор Матрешки
Акуна матата, чето не очень та статья мне вообще зашла, пишу еще одну, точнее вторую и последнюю часть
Жирный спойлер, пейлоад сдампил, точнее вытащил. около 36мб дампа, из них 18,5мб с реальным содержимым, около десяти мегабайт кода, который дизассемблируется как обычная библиотека. Внутри гта сан андрес, ну и клиент поверх него, базовый крмп мобайл. Версия гты не третья и не вайс, я проверял, ниже покажу по каким строкам.
После того, как не был дома неделю, решил дополнить статью, ну и потратил весь день, и несколько часов из него я слил на две свои же ошибки. Обе тупые, обе описываю подробно, потому что метод в них и есть.
Три поправки к первой части
Начну с того где я приврал.
Сегмент A на месте не расшифровывается. Я писал, что стаб расшифровывает первый сегмент прямо в памяти, а это оказывается дизинфа. После отработки стаба он лежит точно такой же зашифрованный, энтропия 8.0, один в один как в файле, а пейлоад разворачивается в свежие анонимные mmap по совсем другим адресам. Если кто пойдет дампить с телефона, подстерегать надо mmap а не сегмент.
Хвостовая копия в распаковке не участвует. Я предполагал, что вторая копия образа в конце файла нужна стабу для сверки целостности. Оказалось нет. В уникорн я грузил только PT_LOAD, а хвост с 0x1850000 ни в один load не попадает, то есть в памяти его физически нет. И распаковка при этом проходит целиком. Значит это чистый декой для статических тулзов плюс 23 мегабайта веса, чтобы желающих поубавилось.
Кста, тот декодер с DEADBEEF расшифровывает не пейлоад, а оказывается он работает по блоку 0x101D13C..0x1024954, это тридцать килобайт внутри самого загрузчика, в открытом сегменте B. Там лежит второй стаб, и вот он уже настоящий анпакер
Почему эмулятор уникорн, а не с телефона?
Кратко, память готовится прямыми svc, мимо libc, поэтому хуки на libcшные mmap и mprotect не покажут ничего. gettimeofday в стабе стоит не для красоты, это замер времени, под пошаговой отладкой он палится сразу. В импортах opendir, readdir, stat, dl_iterate_phdr, sigaction, setjmp с longjmp, то есть разведка по /proc, самопроверка сегментов и логика в сигнальных хендлерах.
А в эмуляторе время и память мои. Могу встать на любой инструкции и посмотреть, что уже расшифровано, и рута для этого не надо.
Минус один, но жирнющий, ядро и libc теперь тоже мои. Каждый сискол и каждый вызов libc надо написать самому, ну и сосался я с этим часа три-четыре.
Каркас
Тут все скучно. Грузим по программным заголовкам (не по секциям, секции в этой либе врут), даем стек, отводим арену под будущие mmap, вешаем хук на svc и стартуем с 0x101CA28, того адреса из единственной живой релокации.
Python:
import struct
from unicorn import *
from unicorn.arm64_const import *
d = bytearray(open('libmatreshka.so','rb').read())
e_phoff, = struct.unpack_from('<Q', d, 0x20)
e_phentsize, e_phnum = struct.unpack_from('<HH', d, 0x36)
loads = []
for i in range(e_phnum):
p = e_phoff + i*e_phentsize
t, fl = struct.unpack_from('<II', d, p)
off, va, pa, fsz, msz, al = struct.unpack_from('<QQQQQQ', d, p+8)
if t == 1: loads.append((off, va, fsz, msz))
IMG_END = (max(v+m for _,v,_,m in loads) + 0xffff) & ~0xffff
mu = Uc(UC_ARCH_ARM64, UC_MODE_ARM)
mu.mem_map(0, IMG_END, UC_PROT_ALL)
for off, va, fsz, msz in loads:
mu.mem_write(va, bytes(d[off:off+fsz]))
STACK, SS = 0x30000000, 0x400000
mu.mem_map(STACK, SS, UC_PROT_ALL)
mu.reg_write(UC_ARM64_REG_SP, STACK + SS - 0x1000)
ARENA, AS = 0x40000000, 0x10000000 # отсюда раздаем mmap
mu.mem_map(ARENA, AS, UC_PROT_ALL)
aptr = [ARENA]
def on_intr(mu, intno, ud):
x8 = mu.reg_read(UC_ARM64_REG_X8)
x0 = mu.reg_read(UC_ARM64_REG_X0)
x1 = mu.reg_read(UC_ARM64_REG_X1)
r = 0
if x8 == 222: # mmap
r = x0 if x0 else aptr[0]
if not x0: aptr[0] += (x1 + 0xfff) & ~0xfff
elif x8 in (226, 215): r = 0 # mprotect / munmap
elif x8 == 169: # gettimeofday
if x0: mu.mem_write(x0, struct.pack('<qq', 1000, 137))
mu.reg_write(UC_ARM64_REG_X0, r)
mu.hook_add(UC_HOOK_INTR, on_intr)
mu.reg_write(UC_ARM64_REG_LR, 0x999999990000)
mu.emu_start(0x101ca28, 0x999999990000, timeout=300*1000000)
Зато сразу приятная новость. Вся та обфускация трамплинами, из-за которой в первой части граф рассыпался, эмулятору вообще не мешает. Он идет по реальному потоку и проходит эти adr, add #0x14, br насквозь, даже не заметив. Скрипт разворота нужен только чтобы читать глазами.
Мелочь, на которую я споткнулся
Эмулятор уникорн у меня не исполняет две инструкции, и усыпает с UC_ERR_EXCEPTION на ровном месте.
Первая это mrs Xt, ctr_el0, чтение геометрии кэшей. Стабу она нужна, потому что он строчит код и обязан потом сделать dc cvau и ic ivau. Лечится тем, что возвращаешь настоящее значение, 0x8444C004. Вторая это xar из SHA3 расширения, эмулируется в три строчки как ror64(Vn ^ Vm, imm6) по каждой половинке.
И вот тут пивное пузо, которое стоило мне часа тупого недоумения. UC_HOOK_INTR это хук не на svc, а на все исключения процессора. Второй аргумент это номер: 2 это svc, 1 это undefined instruction. У меня в этом хуке сразу читался x8 как номер сискола, и на неподдержанной инструкции я получал в логе сискол номер 0x41f22e44 и раздумывал, что за системный вызов такой радостный, так что развилка по intno обязательна, и в ветке с единицей чинишь инструкцию руками, выставляя PC+4.
Python:
def fixup(mu, pc):
w, = struct.unpack('<I', bytes(mu.mem_read(pc, 4)))
if (w & 0xFFF00000) == 0xD5300000: # mrs Xt, <sysreg>
rt = w & 0x1f
if rt != 31: mu.reg_write(UC_ARM64_REG_X0 + rt, 0x8444c004) # ctr_el0
elif (w & 0xFFE00000) == 0xCE800000: # xar Vd,Vn,Vm,#imm6
dd, n, m, i6 = w & 0x1f, (w>>5) & 0x1f, (w>>16) & 0x1f, (w>>10) & 0x3f
q = lambda i: mu.reg_read(globals()['UC_ARM64_REG_Q%d' % i])
x = q(n) ^ q(m); M = (1<<64)-1
ror = lambda v, r: (((v>>r) | (v<<(64-r))) & M) if r else v
mu.reg_write(globals()['UC_ARM64_REG_Q%d' % dd],
ror(x & M, i6) | (ror((x>>64) & M, i6) << 64))
else:
return False
mu.reg_write(UC_ARM64_REG_PC, pc + 4)
return True
С этим стаб отрабатывает и расшифровывает свои тридцать килобайт. Цикл записи там str w3,[x8],#4 пока x8 меньше x10, выход на 0x101CFB4, я на этом адресе останавливаюсь и сливаю образ. Поменялось 96 процентов блока.
А в блоке лежит второй стаб, и по константам понятно, что эт за эль мачо такой:
Код:
0x9E3779B97F4A7C15
0xBF58476D1CE4E5B9 SplitMix64, классика
0x94D049BB133111EB
Рядом присутствует neon-миксер, он и есть анпакер:
Код:
LD2 {V26.4S, V27.4S}, [X15], #32 ; читаем 8 слов с деинтерливингом
EOR V28.16B, V26.16B, V0.16B ; xor с раундовым ключом
MUL V29.4S, V28.4S, V1.4S
MUL V28.4S, V28.4S, V2.4S
USRA V28.4S, V29.4S, #0x1D ; сдвиг на 29, тот же микс, но по 4 полосы
EOR V28.16B, V28.16B, V27.16B
... ; и так 14 раундов на блок
ST2 {V27.4S, V28.4S}, [X16], #32
Рядом валяется скалярный близнец того же миксера для хвостов не кратных четырем словам. Таблица раундовых параметров адресуется через x24 = 0x1024BC0, причем свой заголовок на 0xA0 байт стаб расшифровывает сам, тем же миксером. Ну и dc cvau, ic ivau, dsb, isb после, то есть пишет он именно бинарь.
Первая ошибка - выбирает наугад адрес
Вот тут я и завис на пол дня.
Эмуляция проходила два миллиона с лишним инструкций и падала на ST2 {V27.4S,V28.4S}, [X16], #32 по адресу 0x101EC6C. В x16 лежало 0xFFFFFFFD4210E000, то есть отрицательный мусор. Я погнал разбираться в миксер, решил что где-то сам напортачил с эмуляцией xar, потом что не так посчитал границы блока. Ни то ни другое.
x16 приезжает из ldr x16,[sp,#0x38]. Ставлю data breakpoint на этот слот стека, смотрю кто туда пишет, и попадаю в результат mmap. А mmap вернул мусор потому, что мусор ему подсунул я сам: в каркасе выше стоит r = x0 if x0 else alloc(...), просят фиксированный адрес, отдаем фиксированный.
Ну а просит он такое:
Код:
0101E8AC MOV W0, #0x28
0101E8B0 BL sysconf ; x0 = размер страницы, 0x1000
0101E8B8 UDIV X8, X28, X0
... (SplitMix64 крутит x27)
0101E8F0 UDIV X9, X27, X8
0101E8F4 MSUB X28, X9, X8, X27 ; x28 = rnd % диапазон
0101E8FC MADD X8, X0, X28, X22 ; смещение, кратное странице
0101E908 ADR X28, #0x101E908 ; свой собственный адрес
0101E91C SUB X8, X8, X22 ; окно считается относительно своего кода
0101E93C ADD X27, X8, X27
0101E940 BL sysconf
0101E944 NEG X8, X0
0101E948 MOV W3, #0x22
0101E94C MOV X1, X22
0101E950 AND X0, X27, X8 ; выровняли на страницу
0101E954 MOV W2, #3
0101E958 MOVK W3, #0x10, LSL #16 ; flags = 0x100022
0101E95C MOV W4, #-1 ; PRIVATE | ANON | MAP_FIXED_NOREPLACE
0101E964 BL mmap
0101E968 CMN X0, #1
0101E96C B.NE ok
0101E970 BL __errno
0101E97C CMP W8, #0x11 ; EEXIST, крутим следующий адрес
0101E980 B.EQ retry
0101E99C BL mmap ; фолбэк, mmap(NULL), пусть ядро выберет
То есть протектор делает себе ASLR своими руками. Берет SplitMix64, выбирает случайный адрес в окне относительно собственного кода и просит его через MAP_FIXED_NOREPLACE. Занято, ядро отдает EEXIST, он крутит следующий. Совсем не срослось, просит mmap(NULL) и берет что дали. Понятно зачем, адрес распакованного пейлоада каждый запуск разный, чужие оффсеты в дампе не работают.
Стаб mmap в эмуляторе не должен возвращать неотображенный адрес, никогда. Просьбу уважаем только если этот диапазон у тебя уже есть, иначе выдаем свой, ядро для не-MAP_FIXED хинта имеет полное право сделать так же, а проверка в коде все равно только на минус единицу.
Обидно очень то, что фолт случается через два миллиона инструкций после самой ошибки, в SIMD-цикле, который к mmap отношения не имеет вообще. Без вотчпоинта на слот стека можно смотреть не туда очень долго, я примерно так и делал.
Ошибка вторая, открывшая мне глаза
Дальше поток уперся в вызовы шести адресов, 0x1015820, 30, 40, 50, 60, 70, и еще одного, 0x10157D0. В файле по этим адресам каша, энтропия высокая, дизасм мусор, а я подумал что это зашифрованные обертки протектора, и стал угадывать их семантику по тому, как их зовут. Рядом neg x11,x0; and x10,ptr,x11, значит возвращает размер страницы. Тройка аргументов dst, src, n, значит memcpy. Написал шесть заглушек, поехали дальше.
Угадал почти все кста, и все равно это был неправильный путь, потому что я ни разу не посмотрел, что лежит по этим адресам в момент вызова, а не в файле. А лежит там вот что:
Код:
010157D0 STP X16, X30, [SP, #-0x10]!
010157D4 ADRP X16, #0x17B0000
010157D8 LDR X17, [X16, #0x930]
010157DC ADD X16, X16, #0x930
010157E0 BR X17
Это обычный PLT-стаб, слово в слово как их генерирует линкер. Никаких оберток протектора не существует в природе, просто сами стабы тоже зашифрованы, и расшифровывает их не слой 0, а уже второй стаб, код по адресу 0x101D9FC. Семьдесят шесть записей в диапазон 0x10157D0..0x10158FC, и было-стало выглядит так:
Код:
до: 9ffd1868 1f76b805 d32b5908 2d32c61f
после: f07bbfa9 d03c00f0 119a44f9 10c22491
stp x16,x30,[sp,#-0x10]! / adrp x16 / ldr x17,[x16,#0x930] / add x16
Проверить это просто: в образе со снятым слоем 0 байты по 0x10157D0 еще равны файлу, а к моменту первого вызова там уже живой стаб. То есть цепочка такая: слой 0 вскрывает второй стаб, второй стаб вскрывает PLT, и только потом эти адреса начинают что-то значить.
А падало все потому, что GOT никто не заполнил. Динамического линкера в эмуляторе нет, слот нулевой, BR X17 прыгает на нуль. Забавная деталь, адрес нуль в образе это elf-заголовок, и уникорн послушно спотыкается об инструкцию 464C457F, то есть об байты \x7fELF.
Правильный ход был вообще не угадывать. Имена импортов лежат в таблице релокаций, и никуда они не делись:
Код:
.rela.plt 758 записей: 428 JUMP_SLOT + 330 NONE
.rela.dyn 7210 записей: 196 GLOB_DAT + 4 RELATIVE + 7010 NONE
всего 466 разных имен
Ну, и что получается. Протектор занулил в none те релокации, которые применяет сам, это 7010 внутренних и 330 записей PLT. А все, что должен привязать настоящий линкер, он оставил как есть, иначе либа просто не загрузится. Значит таблица импортов у него открытая, и по ней имена читаются напрямую:
Код:
слот 0x17B0950 -> sysconf == стаб 0x1015820 (аргумент 0x28, это _SC_PAGESIZE)
слот 0x17B0958 -> memset == стаб 0x1015830
слот 0x17B0960 -> mmap == стаб 0x1015840
слот 0x17B0968 -> memcpy == стаб 0x1015850
слот 0x17B0970 -> __errno == стаб 0x1015860
слот 0x17B0978 -> malloc == стаб 0x1015870 (46 430 вызовов)
слот 0x17B0930 -> ??? == стаб 0x10157D0 <- а вот эта занулена в NONE
Порядок слотов совпал с порядком стабов один в один, так что мои догадки подтвердились уже по именам. Но последняя строчка показательная, у 0x10157D0 релокация как раз из тех 330 стертых, в таблице его слот стоит ровно перед __cxa_finalize, а что там должно быть, по релокациям не скажешь. Это протектор биндит сам.
Ну и тактика, ее и советую забрать, она годится для любого упакованного .so. Парсишь rela.plt и rela.dyn, на каждый JUMP_SLOT выделяешь четыре байта в отдельном трап-регионе, пишешь туда ret, адрес этих четырех байт кладешь в GOT-слот и запоминаешь, какому имени он соответствует. Один узкий хук на весь трап-регион: сработал, посмотрел имя, позвал питоновскую реализацию, поставил PC = LR. Данные (GLOB_DAT, всякие stdout и __sF) заворачиваешь на нулевые фейковые объекты.
И одна вещь, без которой все развалится. Символы, у которых st_shndx не нуль, определены в этой же самой либке, их надо привязывать на st_value, а не на трап. Таких тут всего пять из 428, но каждая критична. Ну и помните мусорные экспорты типа EfuDv из первой части? Они одновременно и в импортах, то есть либка зовет свои же функции через PLT. Перехватишь EfuDv как неизвестный libc-символ, подменишь заглушкой внутреннюю функцию протектора, и поток уедет в никуда. Вот они все:
Код:
EfuDv 0x10156C0 (размер 0x6C)
Efuiv 0x101572C
TuISJrS11COMLThdJVlf 0x1015734
TuIxJrS11COMLThdJVlf 0x10157C4
STxSxXRGRFCGeFZLfRd 0x16E61A4
Арифметика для сверки, живых релокаций с символом 624, это 428 JUMP_SLOT плюс 196 GLOB_DAT, из них локально определенных пять, значит трапов ставится 423.
Python:
TRAP, FAKED = 0x70000000, 0x71000000
mu.mem_map(TRAP, 0x10000, UC_PROT_ALL)
mu.mem_map(FAKED, 0x100000, UC_PROT_ALL)
imports, dataimports = {}, {}
def symat(i): # имя, shndx, value по индексу в .dynsym
st_name, info, other, shndx, val, size = struct.unpack_from('<IBBHQQ', d, dsym+i*24)
b = dstr + st_name
return d[b:d.index(b'\0', b)].decode(), shndx, val
ti = di = 0
for sec in ('.rela.plt', '.rela.dyn'):
off, sz = S[sec][1], S[sec][2]
for o in range(off, off+sz, 24):
r_off, r_info, r_add = struct.unpack_from('<QQq', d, o)
t, sym = r_info & 0xffffffff, r_info >> 32
if t not in (1024, 1025, 1026) or not sym: continue # NONE и прочее мимо
nm, shndx, val = symat(sym)
if shndx and val: # символ определен ЗДЕСЬ же
mu.mem_write(r_off, struct.pack('<Q', val)) # <- вот это критично
continue
if t == 1026: # JUMP_SLOT
pad = TRAP + 4*ti; ti += 1
imports[pad] = nm
mu.mem_write(pad, struct.pack('<I', 0xd65f03c0)) # RET
mu.mem_write(r_off, struct.pack('<Q', pad))
else: # GLOB_DAT
obj = dataimports.setdefault(nm, FAKED + 0x100*di)
if obj == FAKED + 0x100*di: di += 1
mu.mem_write(r_off, struct.pack('<Q', obj))
def on_import(mu, addr, size, ud):
nm = imports.get(addr)
fn = LIBC.get(nm) # словарь {'malloc': ..., 'memcpy': ...}
if fn: fn()
else: print('не реализовано:', nm) # готовый список, что дописать
mu.reg_write(UC_ARM64_REG_PC, mu.reg_read(UC_ARM64_REG_X30))
mu.hook_add(UC_HOOK_CODE, on_import, begin=TRAP, end=TRAP+0xffff)
Побочный приятный эффект как от наркоты: любой неизвестный вызов сам печатает свое имя, и у тебя на руках список того, что надо дописать, мне хватило десятка трех функций. malloc, calloc, realloc, free, memcpy, memset, memmove, strlen, strcmp, strncmp, mmap, mprotect, sysconf, __errno, мьютексы в ноль, pthread_once тейлколлом в переданную функцию, __cxa_atexit, getauxval, __system_property_get.
Про последний интересно. Единственное, что протектор спросил у системы, это свойство ro.arch плюс getauxval с AT_HWCAP и AT_HWCAP2, то есть фичи процессора. Ни ro.debuggable, ни TracerPid, ничего такого на этом этапе нет. Вся антиотладка, судя по импортам, живет уже в распакованном теле.
Про скорость, если будешь повторять
Пока хук на инструкции висел глобально, а именно так удобно ловить mrs, xar и обертки, питон превращался в бутылочное горло, и шестьдесят миллионов инструкций упирались в таймаут, не доезжая до конца распаковки. Помогло вот что: хук на инструкции только на нужный диапазон адресов (у hook_add есть begin и end), неподдержанные инструкции ловить не превентивно, а по исключению, и после первого срабатывания ставить узкий хук ровно на этот PC. Прогресс отслеживать не по инструкциям, а UC_HOOK_BLOCK, в кольцевой буфер на пару десятков адресов, тогда при падении печатаешь последние блоки и сразу видишь весь путь.
Стало четырнадцать секунд. А когда дописал GOT-пады, выяснилось, что хук на PLT-стабы теперь и не нужен, пады делают все сами. Убрал последний хук на инструкции, и вся матрешка снимается за 1.4 секунды. За это время она успевает 11 505 раз позвать mprotect через svc, четыре раза mmap через svc и восемь через libc, 48 398 раз malloc и 88 раз sysconf.
Что вываливается
Восемь буферов. Роли подписал по содержимому, первые два совпадают с секциями оригинала до байта, отсюда и вывод, что это их расшифрованные копии.
Код:
адрес размер занято строк что это
0x40020000 0x30C4C 196 КБ 2332 копия .rodata оригинала (там ровно 0x30C4C)
0x40051000 0x0A9000 380 КБ 258 копия .data оригинала (0xA07E0)
0x400FA000 0x0F11000 10 056 КБ 67 804 тело: код и rodata клиента
0x4100B000 0x01B000 52 КБ 0
0x41033000 0x788000 16 КБ 0 почти пусто, похоже на bss
0x417BB000 0x780000 7 484 КБ 1990 линкер протектора
0x41F3B000 0x36F1F4 704 КБ 0 трамплины, ldr x17 / br x17
0x422AB000 0x005F80 12 КБ 0 трамплины и мелкая таблица
Тело живет по базе 0x3FFF5000, а лоадер держит структуру со всей своей бухгалтерией, у меня в прогоне она была по 0x4103BF90: по нулевому смещению база образа, по +0x28 число импортов (1283), по +0x48 массив записей, по +0x70 массив флагов по байту на импорт.
Три места в лоадере, которые стоит запомнить:
Код:
0x41858C68 пишет примененную релокацию в GOT тела
0x41859030 зануляет GOT-слоты импортов, у которых флаг привязки не выставлен
0x418590C0 гоняет .init_array тела: ldr x9,[x19,x20,lsl#3] / cbz / BLR X9
Как из этого собрать нормальный файл
Первым делом хотелось сделать красиво: вычесть базу 0x3FFF5000, восстановить исходные VA и собрать аккуратный ELF с нуля. Не делай так. В дампе лежат уже примененные релокации, GOT-слоты и указатели в данных содержат абсолютные адреса вида 0x40A2DA94. Сдвинешь образ, и все они станут висячими, ни одного перехода по указателю не построится.
Так что правило обратное интуиции: виртуальные адреса в собранном файле должны совпадать с адресами, по которым буферы лежали в эмуляторе. Тогда все сходится само, и adrp, и GOT, и таблицы виртуальных функций.
Дальше восемь PT_LOAD, по буферу на сегмент, и таблица секций с осмысленными именами. Плюс мелочь, которая сильно экономит время потом: символы. Взять их особо негде, но у лоадера есть init_array тела, это база плюс смещение из [x24+0x1c], длина в [x24+0x20]. Там 154 конструктора по адресу 0x40A7ADF8, и каждый это законная точка входа в тело. Вываливаем их как payload_init_000 и далее, плюс три адреса лоадера выше.
Весь путь от исходной либы до готового файла уложился в один скрипт, зависимость одна, unicorn. Печатает он вот такое:
Код:
input : libmatreshka.so (49095216 bytes, 4 LOAD segments, image ends at 0x1fe0000)
423 imports trapped, 5 self-referential symbols bound
running the protector's loader from 0x101ca28 ...
unhandled insn 464c457f at 0x0
1.4s, syscalls {'0xde': 4, '0xe2': 11505, '0xd7': 4}
top libc calls: malloc=48398, memset=1971, pthread_getspecific=984, ...
payload base=0x3fff5000 init_array=0x40a7adf8 (154 ctors), imports left unbound=1283
output: libmatreshka_payload.so (36203624 bytes, 8 segments, 158 symbols)
0x400fa000 size 0xf11000 10056 KB used .payload
0x417bb000 size 0x780000 7484 KB used .loader
...
18.5 MB of non-zero content
sanity check, markers found in the dump: data/matreshka/main.scm, JNI_OnLoad, InitEGLAndGLES2
Строка про unhandled insn в этом логе выглядит как поломка, но так и должно быть. Это остановка внутри первого конструктора тела, на вызове через нулевой GOT-слот, и те самые 464C457F это опять же байты \x7fELF, эмулятор доехал до нуля и попробовал исполнить заголовок. Распаковка к этому моменту уже закончена, что и подтверждает последняя строка.
И что там внутри
В первой части строк не было вообще, все шифрованное. Теперь их 67 804 в пейлоаде и еще 2332 в расшифрованной rodata. Вот характерный набор:
Код:
gta_sa.set
GROVE1c / SWEET2B / WOOZIE4 / DESERT2 / FINAL2A / BCESA4W / DATE1b
BEDROOM_WARDROBES / MISC_CLOTHES
SATCHEL_CHARGE / MICRO_UZI / KATANA / BASEBALLBAT
data/matreshka/main.scm
data/matreshka/peds.ide / ped.ifp / vehicles.ide
data/matreshka/fonts/personal_number_font.ttf
uniform sampler2D Diffuse; ... BoneToLocal[2] = Bones[BlendIndexArray.x*3+2]
mpg123/libmpg123/frame.c / png_write_image / unsupported zlib version
JNI_OnLoad called / InitEGLAndGLES2 / NV_EVENT_PAUSE
CTextDrawPool / CGangZonePool
Движок это San Andreas, тут без вариантов. Решают gta_sa.set и мисочные идентификаторы, GROVE1c, SWEET2B, WOOZIE4 и прочие, плюс тряпки с BEDROOM_WARDROBES, которых ни в третьем, ни в вайсе нет. Маркеров третьего и вайса (gta3.img, PORTLAND, STAUNTON, gta_vc, VICE CITY) в дампе нет ни одного, я специально прогонял даже.
Сбить с толку может другое. В дампе полно итальянских и французских названий цветов покраски, всякие Viola Schafter и Mauve Schafter metallise, и ссылки на Rockstar Social Club. Выглядит как из другой части серии, но это таблицы локализации и социалклаб мобильного порта, к движку отношения не имеют.
Клиент поверх самповского происхождения, на это указывают CTextDrawPool и CGangZonePool, в чистой гте таких классов нет. А вот RakNet, SAMP и 0.3.7 не находятся вообще, CNetGame с CPlayerPool по RTTI тоже. Значит сетевую часть либо переименовали в форке, либо ее строки шифруются отдельно. Кто полезет копать нетворк, я бы начинал от этих двух пулов и от конструкторов, а не от поиска строк.
Свои данные крмпхи лежат в data/matreshka, так что это не порт как есть, а именно пересобранная сборка.
Мелочь напоследок про PLT в пейлоаде. Стабов там 9178, движок жирный, и 315 из них ведут в нулевой слот, это те импорты, которые лоадер не привязал. Их удобно сразу помечать, чтобы при чтении понимать, что вот этот вызов в живой игре уходит в libc, а у нас про него ничего не известно. Скрипт на idapython в спойлере, он же поднимает стабы в функции, чтобы графы строились.
Python:
import idc, idautils, ida_bytes, ida_funcs, ida_segment
BR_X17 = 0xD61F0220
def setname(ea, nm):
try: idc.set_name(ea, nm, idc.SN_NOWARN | idc.SN_NOCHECK)
except: idc.set_name(ea, nm)
def adrp_target(ea, w):
imm = (((w >> 5) & 0x7FFFF) << 2) | ((w >> 29) & 3)
if imm & (1 << 20): imm -= 1 << 21
return (ea & ~0xFFF) + (imm << 12)
named = unres = 0
for i in range(ida_segment.get_segm_qty()):
s = ida_segment.getnseg(i)
size = s.end_ea - s.start_ea
buf = ida_bytes.get_bytes(s.start_ea, size)
if not buf: continue
for off in range(0, size - 16, 4):
w0 = int.from_bytes(buf[off:off+4], 'little')
if (w0 & 0x9F00001F) != 0x90000010: continue # adrp x16, #..
w1 = int.from_bytes(buf[off+4:off+8], 'little')
if (w1 & 0xFFC003FF) != 0xF9400211: continue # ldr x17,[x16,#imm]
if int.from_bytes(buf[off+12:off+16], 'little') != BR_X17: continue
ea = s.start_ea + off
got = adrp_target(ea, w0) + ((w1 >> 10) & 0xFFF) * 8
slot = ida_bytes.get_qword(got)
prev = int.from_bytes(buf[off-4:off], 'little') if off >= 4 else 0
stub = ea - 4 if prev == 0xA9BF7BF0 else ea # stp x16,x30,[sp,#-16]!
ida_funcs.add_func(stub)
if slot:
setname(stub, "plt_%x" % got); named += 1
else:
setname(stub, "plt_UNRESOLVED_%x" % got)
idc.set_cmt(stub, "GOT %#x == 0, импорт не привязан" % got, 1)
unres += 1
print("PLT: %d привязано, %d нулевых" % (named, unres))
for ea, nm in idautils.Names():
if nm.startswith("payload_init_"): ida_funcs.add_func(ea)
Чего не выкопал
Те самые 1283 импорта тела так и остались нулями. Механика такая: у лоадера есть массив флагов, привязан символ или нет, и цикл 0x41859030 зануляет GOT-слот у каждого, где флаг не выставлен. В моем прогоне не выставлен ни один, то есть проход, который эти флаги ставит, вообще не запускался. Лоадер там сделан автоматом по фазам, фаза лежит в [x24+0x20], и на моем пути он сразу ушел гонять init_array. Из-за этого первый конструктор тела и падает на вызове через нулевой слот 0x40A83D98, по контексту это __cxa_atexit. Для чтения кода это не мешает совсем, тело-то расшифровано. А вот чтобы доехать в эмуляторе до живого JNI_OnLoad, надо либо доводить автомат до фазы резолва, либо раскидать по слотам трап-пады самому.
Имена импортов тела тоже не вытащил. У лоадера они лежат записями по 0x3A байта, имя обфусцировано, у первых записей общий префикс F3 87 17 75 C5 C8 C5 9B, различается только u32 по смещению +8, и это как раз смещение GOT-слота. Однобайтовым xor не берется, дальше я не полез. Обидно, потому что 1283 подписанных импорта это готовая карта того, какая подсистема что зовет.
И целостность с антиотладкой внутри тела я не смотрел вообще. Все, что тут описано, это загрузчик. Что там за самопроверки в самом клиенте, а по импортам они точно есть, это отдельная история и отдельный вечер.
Если коротко про то, что я вынес из этих трех вечеров. Грузить надо по program headers, секции в такой либе врут по определению. Стаб mmap не должен возвращать адрес, которого у тебя нет, иначе получишь фолт через два миллиона инструкций совсем в другом месте. И главное, не угадывать семантику стабов по call-site'ам, а читать таблицу релокаций: пакер физически не может занулить то, что должен привязать линкер, поэтому имена импортов у него всегда открыты. Вот это, пожалуй, единственное действительно слабое место всей конструкции.
А конструкция сама по себе крепкая. Энтрипоинт, спрятанная релокацией. Шифр в два приема, сначала тридцать килобайт второго стаба, потом уже десять мегабайт тела. Свой ASLR у распаковщика. PLT в зашифрованном блоке. Свой линкер с релоками и резолвером. И все на прямых сисколах мимо libc. Каждый трюк по отдельности несложный, а вместе они дают ровно то, чего разрабы хотели: пока не расшифровал и не починил указатели, смотреть нечего.
Энтропию считал питоном, дизассемблировал идой, эмуляция на уникорне, capstone обязательно с skipdata, вопросы и поправки в комменты.