Привет Хабр! В 2014 году автор под никнеймом xorpd опубликовал книгу, которая полностью состоит из ассемблерного листинга, в ней нет ни одного комментария а в поле "от автора" написаны несколько строк машинного кода. Его задумка в том что бы читатели сами поняли что означают все эти строки кода и для чего они вообще нужны. Я самостоятельно разобрал эту книгу и хочу поделиться с вами интересными трюками, которые автор оставил читателям.

Книга выложена в открытом доступе на одноименном сайте. (настоятельно рекомендую посетить, сайт очень интересный)

Так как я не видел ни одного обзора этой книги на Хабре или на каких-то русскоязычных ресурсах, надеюсь что моя статья о ней будет первой, а приведенные тут примеры окажутся полезными.

Я хочу разделить материал посвященный этой книге на несколько частей, в этой статье будут рассмотрены более простые фрагменты, а вторая и третья часть будут посвящаны более сложным хакам, связанным с алгоритмами

Для наглядонсти мы будем писать некоторые из примеров книги на fasm под LInux. Статья также подразумевает у читателя наличие базового понимания языка ассемблера для архитектуры х86 и базовых знаний битовой арифметики.

Стоит сказать так же пару слов о имени автора и названии книги, ведь они тоже являются ассемблерным кодом. xorpd это инструкция предназначенная для того что бы ксорить 128-битные регистры. Что-то вроде "страшего брата" обычного xor. А название книги xchg rax, raxэто ни что иное как "перефразированная" инструкция nop. Которая не делает ничего. Компиляторы часто вставляют вместо нее подобного рода строчки. Инструкция xchgпросто меняет местами значения регистров.

И так, начнем наверное вот с такого простого примера: (страница 0x01)

.loop:
			xadd   rax, rdx
      loop   .loop
Можете подумать пару минут о том что же этот кусочек кода может делать :)

С помощью этого маленького цикла можно вычислять числа Фибоначчи. Для кого-то это может прозвучать странно, но давайте разберемся как это работает.

Что-бы наглядно посмотреть работу этого кода, напишем простую функцию вывода десятичных чисел в терминал. Кладем в rax любое 64-битное число и вызываем нашу процедуру. Подробно ее работу я разбирать не буду, хоть в в ней и присутствеут определенная красота. Если очень кратко: мы тут просто рекурсивно делим число на 10, пока оно не будет равно нулю, а к остатку прибавляем символ нуля, что бы получить правильную ASCII кодировку цифры. Кидаем на стек значения символов и потом достаем их в rax. Далее просто выводим каждый из символов в терминал с помощью системного вызова write(). Инфы про нее в интернете достаточно, так что думаю что все смогут разобраться.

print_number:

   xor rdx, rdx
   mov rcx, 10
   div rcx
   add rdx, '0'
   push rdx
   or rax, rax
   jz short .output
   call print_number
 .output:
   pop rax
   call print_symbol
   
   ret
    
print_symbol:
  push rdx
  push rsi
  push rdi
  push rax

  mov rax, 1
  mov rdi, 1
  mov rsi, rsp
  mov rdx, 1
  syscall

  pop rax
  pop rdi
  pop rsi
  pop rdx

  ret

Стоит отметить что, этот способ конечно валидный, но достаточно корявый, в идеале в процедуре print_number нужно сохранять все регистры на стеке, да и в целом тут очень много стои поменять, но она тут исключительно для того, что-бы вывести результат работы кода.

И так, начнем с того что регистр rcx в циклах выступает счетчиком, от чего и называется "re-extended counter", соответственно мы будем вычислять rcx-ый элемент последовательности чисел Фибоначчи. rdx вначале должен быть равен единице, а rax нулю. Протестируем на данном примере:

Создадим для удобства простенький makefile и соберем нашу программку.

default: build run
build:
	fasm test.asm && ld test.o -o test
 run:
 	./test
_start:
  		mov rcx, 9
  		mov rdx, 1
.loop:
  		xadd rax, rdx
  		loop  .loop
  		call print_number

После запуска мы увидим результат 34, все работает корректно, давайте теперь разберем как это работает.

Инструкция loop отрабатывает метку, заданную в операнде rcx раз. Самое интересное происходит во время выполнения инструкции xadd. Она складываеет два операнда, после чего меняет их местами, в данном случае на первой итерации она складывает rax и rdx, сохраняя результат в rax, далее на следующей итерации уже оба регистра содержат единицу. Далее думаю уже не так сложно будет проследить этот механизм.

Следущий в очереди вот такой код: (страница 0x15)

		mov      rdx,0xffffffff80000000
    add      rax,rdx
    xor      rax,rdx
Объянение

Тут может быть не сразу понятно в чем дело, но это приведение 32-битного числа в младших 32 битах rax к 64-битному числу. Стоит отметить, что для корректной работы нужно выставить в ноль старшие 32 биты в rax. Как же это работает? На самом деле все довольно просто.

После сложения с помощью инструкции add число изeax из за порядка хранения байтов от младшего к старшему, попадает в младшие байты числа из rdx, и например при rax=0x00000025, после сложения получится 0xfffffffff80000025, так как старшие байты rax равны нулю, в них ничего не меняется, а дальше после xor в rax остается уже расширенное до 64 бит число. Старшие байты rax и rdx совпадают и становятся нулями, далее совпадающие биты в младших байтах обнуляются оставляя нам наше число. Результат сохраняется уже как 64-битное число в rax. Более подробно все это вы можете посмотреть в отладчике.

Далее вот такая строчка: (страница 0x04)

xor		al, 0x20

Над ней я думал достаточно долго. Дело в том что разница в ascii кодировке заглавных и строчных символов везде равна 20. К примеру "А" = 41, а строчная "а" = 61 или "Z" = 5a, "z" = 7a. Эта строчка позволяет поменять строчный символ на заглавный, и наоборот всего за одну машинную инструкцию! Как именно происходит вычисление кодировки символа думаю можно не обьяснять. Красиво, неправда ли? Можем написать маленькую программку которая будет менять все символыстроки с использованием команды xlatb Эта инструкция возвращает байт по индексу, хранящемуся в al, из массива, на который указывает rbx

string db "hello world", 0

_start:
      mov rbx, string
_loop:
      cmp [rbx], byte 0
      je short exit
      xor al, al
      xlatb
      cmp al, 0x20
      jz short _space
      xor al, 0x20
      call print_symbol
      inc rbx
      jmp short _loop
exit:
      xor rbx, rbx
      mov rax, 1
      int 80h
_space:
      call print_symbol
      inc rbx
      jmp short _loop

Создаем директиву со строкой, передаем ее в rbx, а потом просто увеличиваем его в цикле пока не дойдем до нуль терминатора, попутно возвращая байты по нулевому индексу в al. Тут я использую jmp short вместо простого jmp т.к она занимает в разы меньше места, но передавать управление может только на 128(примерно) байт. Для вывода их на экран пользуемся процедурой print_symbol, реализованной выше. Так же проверяем строку на наличие пробелов и корректно обрабатываем их. Метка exit просто делает системный вызов exit() .Эта программа выведет "HELLO WORLD". Можете протестировать этот код самтостоятельно и проверить его работу.

Рассмотрим что-то более сложное: (страница 0x3a)

    mov      qword [rbx + 8*rcx],0
    mov      qword [rbx + 8*rdx],1
    mov      rax,qword [rbx + 8*rcx]

    mov      qword [rbx],rsi
    mov      qword [rbx + 8],rdi
    mov      rax,qword [rbx + 8*rax]

Вот тут уже начинаете что-то более интересное.

Наверное для начала стоит пояснить, вычисления которые тут производятся: В языке ассемблера это является распространенным способом доступа к элементам массива. Выглядит он следующим образом:

mov [ адресс начала массива + выравнивание * индекс ], значение

Под выравниваем очевидно имеется ввиду размер элементов в массиве, в данном случае размер элементов 8, значит массив выровнен по 8 байт.

И так в rbx у нас указывает на некую память, выровненную по 8 байт , далее rax и rcx должны содержать какие-то индексы, по котоым кладется единичка и ноль. Далее мы в rax возвращаем значение по индексу rcx

Кладем rsi и rdi по соседству в нашу память и возвращаем в rax что-то по индексу который мы вычеслили в первом блоке кода.

Можете так же подумать пару минут о том что же может происходить дальше :)

Суть магии

И так, если rcx == rdx (к примеру оба равны нулю) то сначала в нашу память по индексу rcx кладется 0, а потом туда же единица и индекс вычесленный в этом блоке будет равен единице, если же нет, то там будет 0 соответственно.

А дальше мы возвращаем в rax либо rdi либо rsi в зависимости от того какой получился индекс. По сути мы выполнили сравнение и условное перемещение с помощью одних только инструкций mov, мне кажется это весьма интересным.

Один проект с гитхаба, под названием M/o/Vfuscator, доводит эту идею до компилятора С, который выдает бинарники, содержащие только инструкции mov :)

Не судите слишком строго, это моя первая статья.

В следующей статье разберу более сложные примеры из этой книги, касающиеся алгоритмов. Если обнаружите в моем коде ошибки или недоработки, пишите в комменты, я буду очень рад выслушать ваши советы.

Всем большое спасибо за внимание!

Комментарии (25)


  1. titbit
    10.02.2022 13:08
    +4

    Прикольно, но уж больно много допущений делает автор. Надо догадываться буквально обо всём, о параметрах, об очень ограниченной применимости и т.д. Но как головоломка — годится :)
    p.s.
    mov eax, [x]
    shr eax, 1
    xor eax, [x]


    1. RigelGL
      11.02.2022 08:16

      y = x ^ (x / 2);

      Кому любопытно,

      Это

      Код Грея


  1. amarao
    10.02.2022 13:38
    +59

    Спасибо, не надо. У меня на работе и без того хватает кусков исходных текстов, по которым нужно догадаться, "что имел в виду автор".


  1. berez
    10.02.2022 16:59
    +1

    Тут может быть не сразу понятно в чем дело, но это приведение 32-битного числа в младших 32 битах rax к 64-битному числу.

    А чем не угодила инструкция movx? Она как раз для того и сделана, чтобы числа расширять.


    1. lvtslst Автор
      10.02.2022 17:32
      +1

      Честно говоря не знаю, может автору она по какой-то причине не нравится, но думаю что мы этого уже не узнаём


  1. ComodoHacker
    10.02.2022 19:56
    +7

    То самое ощущение, что Хабр снова торт.

    Спасибо!


  1. GBR-613
    10.02.2022 21:01
    +4

    Куча ошибок. Например, в выражении "Красиво, неправда ли" надо писать раздельно: "не правда ли".

    Чем русский язык хуже FASM? Почему его не учат?


    1. GospodinKolhoznik
      10.02.2022 22:10
      +2

      Ну вы вбейте в строку поиска на сайте hh один запрос ассемблер и другой запрос русский язык и вам все станет понятно.


      1. GBR-613
        11.02.2022 15:50

        Нет, это Вы сначала запятую после слова "язык" вбейте :-)

        Действительно, есть такое мнение, что русский язык перестанет использоваться раньше, чем COBOL. Но я считаю это преувеличением :-))


    1. Wesha
      11.02.2022 03:37
      +2

      Вы хотите сказать, что русский язык столь же однозначен, как и ассемблер? Ну да, конечно... :/ :)


      1. FruTb
        12.02.2022 03:57

        Да ну нет, вряд-ли но может быть


    1. DmitryKoterov
      11.02.2022 07:36
      +3

      Меня больше всего этот вездесущий на Хабре "и так" добивает. "- и как? - и так!"


  1. AterCattus
    10.02.2022 21:19
    +1

    далее rax и rcx должны содержать какие-то индексы, по котоым кладется единичка и ноль

    Все же rcx и rdx. И оно на странице 0x2 :)

    Еще вспомнилось, как через инструкцию AAM (деление AL на 10) можно было делить AL на любое другое значение, потому что инструкция была в два байта, а второй байт был как раз делителем. Ну и все "недокументированно" пользовались.


    1. lvtslst Автор
      10.02.2022 22:15

      Прошу прощения, опечатался


      1. Wesha
        11.02.2022 03:38

        Прошу прощения, опечатался

        https://www.youtube.com/watch?v=Tv1d2n7Zt04 :)


    1. up40k
      11.02.2022 10:07

      И оно на странице 0x2 :)

      0x2c =)


      1. AterCattus
        11.02.2022 14:33

        Блин, сам же и опечатался :) Но хоть ссылка верная.


  1. TheCalligrapher
    11.02.2022 06:35
    +1

    Самое интересное происходит во время выполнения инструкции xadd. Она складываеет два операнда, после чего меняет их местами,

    Нет. Она сначала меняет их местами, а потом складывает.

    После сложения с помощью инструкции add число изeax из за порядка хранения байтов от младшего к старшему, попадает в младшие байты числа из rdx

    Чего? С каких это пор к регистрам процессора стало применимо понятие "порядка хранения байтов"???


    1. middle
      11.02.2022 19:50
      +1

      Нет. Она сначала меняет их местами, а потом складывает.

      Просто предъявите программу, которая различает эти два варианта.


      1. TheCalligrapher
        11.02.2022 22:01
        +1

        Так вот же она выше - уже предъявлена прямо в статье. (Понятно, что последователность Фибоначчи будут генерировать оба варианта поведения, но расклад результата по регистрам будет разным.)

        В качестве удобного правила постарайтесь запомнить, что команды ассемблера x86 формируют результат в первом операнде (для Intel-синтаксиса). Это простое правило срабатывает и здесь: сумма после выполнения xadd находится в первом операнде. Вот поэтому: сначала она меняет их местами, а потом складывает.

        Если бы команда сначала складывала, а потом меняла местами, то сумма оказалась бы во втором операнде. Видите, какая бы ерунда получилась?


    1. p07a1330
      12.02.2022 10:21

      Вероятнее всего имеется ввиду little endian/big endian


      1. TheCalligrapher
        12.02.2022 19:38

        Деление на "little endian/big endian" относится к тому, как старшие/младшие по значимости байты представления значения проецируются на старшие/младшие адреса памяти. Понятие "little endian/big endian" применимо только к представлению данных в адресуемой (побайтно) памяти. К регистрам процессора это деление неприменимо.


  1. jok40
    11.02.2022 10:32
    +2

    Функции print_number самое место в хабе «Ненормальное программирование».


  1. apachik
    11.02.2022 12:33

    Дело в том что разница в ascii кодировке заглавных и строчных символов везде равна 20. К примеру "А" = 41, а строчная "а" = 61 или "Z" = 5a, "z" = 7a.

    поправьте плиз. тут все числа даны в шестнадцатеричной системе счисления. Т.е. 0x20 и т.д, а то какая-то ерунда получается.


  1. jcmvbkbc
    12.02.2022 12:09

    Тут может быть не сразу понятно в чем дело, но это приведение 32-битного числа в младших 32 битах rax к 64-битному числу.

    Правильнее было бы сказать "знаковое расширение 32-битного числа до 64 битов". Потому что если в eax было отрицательное число, то после сложения с 0xffffffff80000000 старшие биты rax станут нулями из-за переноса из 31-го разряда, а после xor -- опять станут единицами, как и положено отрицательному числу.

    Трюк имеет смысл, потому что позволяет (изменением битовой маски загружаемой в rdx) выполнять знаковое расширение исходного числа с произвольным количеством битов, в отличие от инструкций movsx, работающих только с байтами/словами.