Главная / Блог / Как защитить пароль от реверса: антиотладка, XOR и прыжки в бездну

Как защитить пароль от реверса: антиотладка, XOR и прыжки в бездну

14 ИЮЛЬ, 2025
Как защитить пароль от реверса: антиотладка, XOR и прыжки в бездну
Как защитить пароль от реверса: антиотладка, XOR и прыжки в бездну
Перед любым программистом рано или поздно встаёт вопрос: «Как спрятать пароль от взломщика?»

В современных программах десятки тысяч строк кода, потеряться среди которых не составит особого труда. Но проблема в том, что введённый юзером пасс мы должны сравнить со своим оригиналом, и по результатам проверки или принять пассажира на борт, ну или попытать счастья в следующий раз. Таким образом, в этой схеме сразу две уязвимости, от которых нам нужно избавиться – это «Сравнить» и «..в следующий раз».

Если в человеческой речи достаточно перефразировать два этих слова на синонимы, то в программной реализации упираемся лишь в инструкции ассемблера CMP (compare, сравнить), и JE (jump if equal, прыжок если равно), без какой-либо альтернативы. Чтобы обмануть машину, взломщик может элементарно обратить условие проверок с «Правда» на «Ложь», как все наши усилия превратятся в корм для рыб. При этом без разницы, строку мы проверям, или хэш от неё – проверка по любому должна присутствовать. Выходов из этой ситуации можно придумать несколько, а на практике желательно вообще собрать из них союз. Вот парочка вариаций на эту тему.

Отказ от сообщения «Bad password»

Чтобы обнаружить штаб-квартиру защитного механизма, первым делом взломщик ищет текстовые строки, которые выводит софт при вводе неправильного пароля. Они сразу бросаются в глаза даже при статическом анализе кода в дизассемблере IDA. Далее запрашиваются перекрёстные ссылки на эту строку, и где-то в окрестностях обязательно будет палёная JE. Подобного рода уведомления можно сравнить с добровольной сдачей в плен, т.к. мы сами выходим из окопов с поднятыми руками, даже не пытаясь хоть как-то противостоять злобному хацкеру.

А что если взяв пример со взломщика мы тоже инверсируем условие, и будем выводить сообщение только когда юзер введёт наоборот правильный пароль, а ошибочный молча проглатывать? Тогда окажемся на равных используя то-же оружие, которое применяют против нас. Саму проверку на валидность имеет смысл отбросить куда-нибудь подальше от места непосредственного приёма пароля, чтобы она не маячила на глазах. То-есть приняв пасс мы как ни в чём не бывало идём дальше, а когда юзер ничего не понимая расслабится, преподнесём ему неожиданный сюрприз. Если пасс окажется левый, можно в самый ответственный момент урезать функционал софта, или генерить умышленные глюки типа «calc» вместо «paint» – здесь уже всё зависит от фантазии. Главное не сосредотачивать оборону в одном месте, а размазать её буквально по всему двоичному коду программы.

Использовать пароль как смещение на метку ОК

Чтобы полностью исключить проверку пароля уже приевшейся парочкой CMP + JE, можно использовать пароль в качестве аргумента к инструкции непосредственного перехода JMP xx, где «xx» представляет собой адрес метки Good. Если юзер решит подсунуть нашей прожке «утку», то адрес перехода будет указывать уже на совсем другой участок кода, что нарушит весь алгоритм работы софта. Это вариант, который в принципе невозможно взломать, поскольку сравнений нет от слова «вообще», а прыжок осуществляется с закрытыми глазами в бездну на авось.

Такой расклад предъявляет важные требования к реализации данного механизма защиты, и в первую очередь это связано с переменным размером инструкций в архитектуре х86, которая пляшет в диапазоне от 1 до 15-ти байт. Как результат, джамп в никуда вполне может «разорвать» инструкцию пополам (т.е. прыгнуть в её середину), в результате чего процессор сгенерит исключения #UD «UnDefined instruction». Даже если нам повезёт и JMP выстрелит в начало инструкции, с вероятностью 99% крах приложения всё равно неминуем, поэтому нужно заранее предусмотреть перехват ошибок в коде. На системах х32 проблему решает механизм SEH (структурный обработчик исключений), а на х64 «Vectored Exception Handler» VEH.

Таким образом, при вводе неправильного пароля, сюдьбу нашего приложения полностью решает SEH, внутри которого с чистой совестью можно выводить уже любые сообщения об ошибке. Обнаружив текстовую строку «Bad Password», зевая от скуки хакер по привычке поставит на неё в отладчике бряк, однако при срабатывании окажется, что адрес вызова этого сообщения меняется от пароля к паролю, т.к. будет напрямую зависеть от того, куда именно прыгнет JMP. Мы окажемся в более выгодном положении, если по волею судьбы в окресностях кода окажется инструкция CMP – пусть хакер исследует её природу, ведь она всё-равно не играет никакой роли, а оказалась там чисто случайно. В общем здесь есть над чем поразмыслить.

Ладно, с обработкой ошибки ввода пароля разобрались. А что насчёт валидного пасса?

Честных юзеров мы уважаем, а потому должны постелить им ковровую дорожку прямо до исправно функционирующего участка кода, т.е. метки Good. Наша цель – логикой преобразовать строку с паролем, в указатель. Для этого можно использовать открытый рандом, которым про-XOR-ить введённую строку. Далее логическими сдвигами SHL/SHR отрезаем всё лишнее хоть спереди хоть сзади (не подумайте плохого), чтобы получить таким образом 32-битный адрес метки для JMP. Ксор удобен здесь тем, что повторное его использование с тем-же ключом возвращает оригинальное значение – этот факт позволит нам жонглировать числами на подготовительном этапе рассчёта пары «Пароль-Указатель».

Как говорил один из гениев нашего времени Линус Торвальдс: «Болтовня ничего не стоит – покажите мне код!»

Напишем небольшое демо-приложение на ассемблере fasm для запроса пароля, в качестве которого будет выступать строка «HackerLab».

    format  pe console
    include 'win32ax.inc'
    entry   start
    ;//----------
    .data
    badPass    db   10,' Bad Password!',0
    typePass   db   10,' Type pass: ',0
    frmt       db   '%s',0
    pass       rb   256    ;// буфер под строку с паролем

    ;//----------
    .code
    ;// ставим свой SEH-обработчик
    proc  mySEH  pRecord, pFrame, pContext, pParam
    cinvoke  printf,badPass
    cinvoke  _getch
    cinvoke  exit,0
    endp
    ;//***********************************************************
    ;//************** ТОЧКА ВХОДА В ПРОГРАММУ ********************
    ;//***********************************************************
    start:   push    mySEH             ;// регистрируем SEH фрейм
    push    dword[fs:0]
    mov     dword[fs:0],esp

    ;// здесь какой-то программный код
    db      4 dup(90h)           ;// 4 инструкции NOP

    ;// принимаем пасс у юзера
    cinvoke  printf,typePass
    cinvoke  scanf,frmt,pass

    ;// превращаем пароль в указатель
    mov     eax,dword[pass]      ;// взять первые 4 символа пароля
    add     eax,dword[pass+4]    ;// прибавить к ним вторые 4 символа
    shr     eax,6                ;// сдвиг числа на 6 бит вправо
    xor     eax,0x03728f3d       ;// открытый ключ - вычислить заранее
    jmp     eax                  ;// попытать счастья!

    ;// здесь какой-то код
    db      4 dup(90h)

    ;// Внимание!
    ;// Если пасс ОК, "jmp eax" отправит нас сюда!
    @good:   mov     eax,ebx       ;// произвольные инструкции..
    xchg    ecx,ebx

    ;// здесь какой-то код
    db      4 dup(90h)

    ;// конец программы
    cinvoke  _getch
    cinvoke  exit,0
    ;//----------
    section '.idata' import data readable
    library   msvcrt, 'msvcrt.dll'
    include  'api\msvcrt.inc'

Ну здесь всё в штатном режиме, а под грифом секретно находится лишь блок манипуляции с паролем.

Чтобы после произвольной арифметики в регистре EAX появился адрес метки @good, нам нужно заранее вычислить значение открытого ключа для инструкции XOR EAX,key. Для решения этой задачи достаточно вспомнить простую формулу:

    A xor B = C
    B xor C = A

Если спроецировать эту формулу на наш код, то получим следующее:

  1. Произвести любые математические действия со строкой валидного пароля – у меня тупо сложение и сдвиг.
  2. Найти в отладчике адрес метки Good – в моей демке это 00403073.
  3. Ксор пункта(1) с пунктом(2) = открытый ключ, который нужно прописать в программу – у меня получилось 03728f3d.
  4. Ксор пункта(1) с открытым ключом = пункт(2).

Таким образом, если юзер введёт валидный пасс и мы проксорим его открытым ключом, то соглассно формуле получим адрес метки Good, что собственно и требовалось доказать. В противном случае адрес уйдёт в разнос, и рано или поздно управление получит установленный нами обработчик исключений SEH. Схема рабочая, что подтверждает скрин отладчика x64Dbg ниже. Обратите внимание, что в дизассм-листинге дебагера нет инструкции сравнения CMP с последующим условным передом JE. На поиск валидного пароля можно потратить всю оставшуюся жизнь, а если установить ограничение(3) на попытку ввода пароля (в результате чего отвалится брутфорс), то вообще пиши пропало:

ida-eax-ebx

Создание паролей в реальном времени

Следующий способ считается грязным, хотя весьма интересным. Он основан на всё той-же магической формуле XOR выше. Отличие лишь в том, что на этот раз известны адрес перехода и открытый ключ, по которым нужно вычислить сам пароль. Тогда секретная строка будет неизвестна даже нам как разработчикам софта, и чтобы получить её, юзеру придётся отправить на наш сервер некоторую информацию, например о своём бортовом железе. Плюс подхода в том, что сгенерированный таким образом пароль можно будет использовать только на данном узле, т.е. юзер не сможет поделиться пассом со своим другом. В краткой форме алго будет такой:

  1. Читаем серийник диска C:\ или другую уникальную информацию с юзерского узла.
  2. Ксор пункта(1) на адрес метки Good = валидный пасс.
  3. Проксорив любые 2 значения между собой, получим третье.

Заключение

В заключении вот ещё некоторые нюансы для борьбы с инструментами анализа кода.

  • Если в своих программах вы всё-же проверять пароль на валидность по штатной схеме CMP + JE, то имеет смысл принимать ввод в одном потоке Thread, а непосредственно тест проводить в специально созданном втором потоке. Дело в том, что почти все отладчики не могут одновременно работать с двумя и более программными тредами, и ждут от нас ручного переключения с одного на другой. То-есть в любой момент времени, в окне кода отображается только один поток, но не оба сразу.
  • Раньше на системах х32 раньше наблюдался такой глюк: «Если через VirtualAlloc() выделить память в потоке(1), и попытаться освободить её VirtualFree() в соседнем потоке(2), то система тут-же роняло приложение без каких-либо предсмертных сообщений». Такие ошибки трудно ловить в отладчике, а значит можно использовать при вводе неправильного пароля
  • Для переходов на метки Bad/Good целесообразно всегда использовать неявные переходы с использованием регистров, например PUSH EAX --> RET. Поскольку аргумент будет заранее не известен, то дизассемблеры отправятся на скамейку запасных, а в отладчиках придётся нудно трассировать каждую инструкцию.
  • Шифрование это то, что доктор прописал при любой погоде. Дурным тоном считается крипт всего от чердака и до подвала. Лучше использовать динамическое шифрование только критически важных блоков (причём обязательно с разными ключами), о после отработки блока опять зашифровывать его в исходное состояние. Как результат, отвалится создание дампов памяти нашего приложения.
Поделиться

0 комментариев

Пожалуйста, войдите, чтобы оставить комментарий.

Загрузка комментариев...