Курс ЛР9 · Місія 9
Сюжет С.І.Д.↔ АкадемічнаСРС до ЛР⌨️ команди 7 · 1 нова

Місія 9: «Розтин Системи»

📡 С.І.Д.: Ін'єкція пройшла — і, скажу тобі, красиво пройшла. Твій нейроімплант зараз тихо сидить у пам'яті браузера охоронця й чекає команди. Але перш ніж я подам сигнал на фінальний штурм, зробімо одну мою улюблену річ: розберімо, як саме ворожа система «перетравлює» код, який ми їй підкидаємо. Однакова логіка, дві абсолютно різні машини — я їх обидві знаю близько, а зараз покажу й тобі. Час покласти обидві версії на стіл і розітнути.

Стіл перед тобою вже не схожий на робоче місце студента — радше на прозекторський. Ліворуч світиться термінал з одним вікном, праворуч — з другим, і в кожному лежить те саме тіло, розкрите навпіл. Один алгоритм. Дві його посмертні маски. С.І.Д. навмисне дав тобі щось майже дитяче — найбільший спільний дільник, суму цифр числа, — не тому, що складне, а тому, що прозоре: коли логіка тривіальна, кожна відмінність між двома дизасемблюваннями вже не шум складного коду, а чиста, оголена різниця самих цільових машин.

Голограма С.І.Д. висить над столом рівно між двома терміналами — як лампа над прозекторським столом — і не рухається: бірюзова петелька над екраном-обличчям завмерла. «Ти вже писавла WAT руками — і, до речі, гарно писавла, — каже він тепло. — Тепер поклади поруч те, у що той самий алгоритм перетворив би компілятор, якби йому дали звичайний C. І поряд — те саме для x86-64, для реального заліза в терміналах їхньої охорони. Ліворуч — квартал іменованих комірок: там у кожного мешканця своя підписана шухляда, rax, rbx, rsi, а на вході й виході кремезний швейцар щоразу відмикає й замикає двері — оте push/pop, яке ти скоро побачиш голим оком. Праворуч — знайомий тобі Стос зі своєю єдиною стійкою. Коли побачиш обидва розтини пліч-о-пліч, зрозумієш те, що я давно розкусив: WASM не повільніший клон x86-64 і не швидший. Це просто інша модель. Кожна — інший бік, у який компілятор перекладає ту саму думку. Питання лише одне: у який бік б'ємо ми.»

Ти киваєш. Місія не про те, щоб написати новий код. Місія про те, щоб навчитися читати вже написане — розтинати чужий бінарник, як хірург розтинає тіло на столі, і бачити, де в ньому проходять судини викликів, де кістяк циклу, де м'яка, беззахисна тканина пам'яті. Без цієї навички неможливо аудитувати ворожу систему на вразливості. А саме це чекає на тебе далі — у фінальній місії, коли доведеться довіряти власному, зібраному з нуля ядру й перевіряти кожен його опкод, перш ніж пустити по той бік екрана.

📚 ТЕОРЕТИЧНІ ВІДОМОСТІ

Теоретичні відомості

Одна мова — дві цільові машини. Той самий алгоритм, написаний однією мовою вищого рівня (у нашому випадку — C), компілятор перетворює на послідовність інструкцій для конкретної виконавчої машини. Для x86-64 це реальний фізичний процесор з фіксованим набором іменованих регістрів і угодою виклику (calling convention) System V AMD64 ABI — наперед узгодженим правилом того, де саме функція знаходить свої аргументи (тут вони йдуть у регістрах rdi, rsi, rdx, rcx, r8, r9, надлишок — через стек). Для WebAssembly — стекова машина (stack machine) з операційним стеком значень і структурованим керуванням потоком (block/loop/br_if, як ти вже писавла руками в місії 8).

Що «істотне», а що — деталь платформи. Порівняння двох виводів компіляції одного алгоритму показує, що саме є невід'ємною частиною самого алгоритму (кількість арифметичних операцій, порядок обчислень, кількість ітерацій циклу), а що — особливістю конкретної цільової архітектури: як саме зберігаються проміжні значення (у регістрах x86-64 чи на операційному стеці WASM), як організований пролог/епілог виклику функції.

Накладні витрати виклику функції (call overhead) — різниця між «чистою» роботою самої функції та вартістю механізму її виклику: збереження регістрів, побудова кадру стека, передача аргументів. У x86-64 це класичний пролог/епілог:

push  %rbp
mov   %rsp, %rbp
; ... тіло функції ...
pop   %rbp
ret

плюс, за потреби, збереження callee-saved регістрів (rbx, r12r15), якщо функція їх використовує. У WASM механізм виклику інший: рушій сам керує окремим, захищеним від переповнення call-стеком і перевіряє відповідність типів сигнатури при кожному виклику функції (кількість і типи параметрів, тип результату) — саме тому неможливо викликати WASM-функцію з неправильною кількістю аргументів так тихо, як це іноді трапляється в C при неузгодженому прототипі.

Дизасемблювання (disassembly) — процес відновлення асемблерного (чи WAT) представлення з бінарного файлу, тобто зворотне «читання» готового бінарника як тексту інструкцій. Для x86-64 стандартний інструмент — objdump -d з пакета binutils; для WebAssembly — wasm-objdump -d з пакета WABT або wasm2wat для повного повернення до текстового .wat. Дизасемблювання — стандартний прийом безпекового аудиту: перевірити, що скомпільований бінарник відповідає заявленому вихідному коду, і знайти небезпечні патерни роботи з пам'яттю — насамперед відсутність перевірки меж (bounds check) перед доступом до масиву чи буфера за обчисленим індексом.

Розмір файлу не є мірою швидкодії. WASM-модулі часто компактніші за x86-64 об'єктні файли того самого алгоритму — не тому, що виконуються швидше, а тому, що кодування WASM-інструкцій щільніше (по суті — байт-код зі змінною довжиною інструкцій під конкретний набір опкодів — opcode тут означає числовий код, що позначає одну машинну інструкцію), а x86-64 об'єктний файл ще й несе метадані ELF (заголовки секцій, таблицю символів), яких при прямій компіляції маленького файлу відносно багато.

РІВЕНЬ 1 · «3» · ПЕРШОКУРСНИК

Рівень 1 (оцінка «3») — «Першокурсник»

Завдання: скомпілювати один алгоритм у x86-64 та WebAssembly, порівняти розмір і згенеровані інструкції.

Що зробити буквально:

  1. Дописати тіло функції у algo.c за своїм варіантом (gcd, digit_sum або factorial) — сигнатура вже задана в шаблоні.
  2. gcc -O0 -c algo.c -o algo_x86.o, потім objdump -d algo_x86.o > x86.txt.
  3. Скомпілювати той самий алгоритм у WebAssembly (шлях А або Б з build.sh — Emscripten або clang --target=wasm32), отримати algo.wasm.
  4. wasm-objdump -d algo.wasm > wasm.txt.
  5. ls -la algo_x86.o algo.wasm, порахувати кількість рядків-інструкцій у кожному .txt, заповнити comparison.md.

Цього достатньо для «3». Повтори кроки в тому самому порядку.

РІВЕНЬ 2 · «4» · МАГІСТР

Рівень 2 (оцінка «4», захист) — «Магістр»

Завдання: проаналізувати накладні витрати на виклик функцій у обох середовищах.

У x86.txt знайди й познач пролог (push %rbp / mov %rsp,%rbp) та епілог (pop %rbp / ret) — це і є видима вартість виклику в x86-64. Обґрунтуй, у чому полягає аналогічна вартість у WASM (навіть якщо вона не так очевидна в дизасемблюванні — рушій виконує перевірку типів сигнатури і керування call-стеком неявно). Додай розділ «Накладні витрати виклику» в comparison.md. На захисті — поясни різницю усно, порівнявши обидва механізми.

РІВЕНЬ 3 · «5» · ЛЕГЕНДА

Рівень 3 (оцінка «5», розширення) — «Легенда Коледжу»

Завдання: дизасемблювати отримані бінарники для пошуку потенційних вразливостей у керуванні пам'яттю.

Уважно переглянь x86.txt і wasm.txt на предмет патернів доступу до пам'яті без попередньої перевірки меж: у x86-64 — обчислення адреси (lea, mov з базою й індексом) без сусіднього cmp/умовного переходу; у WASM — i32.load/i32.store без попереднього i32.lt_u/br_if, що обмежує адресу. Додай розділ «Аналіз безпеки пам'яті» в comparison.md з висновком — навіть якщо висновок «підозрілих патернів не знайдено, і ось чому» (наш простий алгоритм не працює з масивами, тож пояснити, чому саме тут це не проблема, теж є валідним аналізом).

🧪 РОЗІБРАНИЙ ПРИКЛАД

Розібраний приклад

Приклад — НСД Евкліда (варіант A) у двох компіляціях

Вихідний код (algo.c):

int gcd(int a, int b) {
    while (b != 0) {
        int t = b;
        b = a % b;
        a = t;
    }
    return a;
}

Компіляція в x86-64 (gcc -O0 -c algo.c -o algo_x86.o, потім objdump -d algo_x86.o) дає приблизно такий фрагмент (спрощено, без адрес):

gcd:
    push   %rbp
    mov    %rsp,%rbp
    mov    %edi,-0x14(%rbp)      ; a -> стек
    mov    %esi,-0x18(%rbp)      ; b -> стек
.loop:
    cmpl   $0x0,-0x18(%rbp)
    je     .end
    mov    -0x18(%rbp),%eax
    mov    %eax,-0x1c(%rbp)      ; t = b
    mov    -0x14(%rbp),%eax
    cltd
    idivl  -0x18(%rbp)
    mov    %edx,-0x18(%rbp)      ; b = a % b
    mov    -0x1c(%rbp),%eax
    mov    %eax,-0x14(%rbp)      ; a = t
    jmp    .loop
.end:
    mov    -0x14(%rbp),%eax
    pop    %rbp
    ret

Помітно: параметри одразу копіюються з регістрів (%edi, %esi) у кадр стека — це типово для компіляції без оптимізацій (-O0), компілятор навіть не намагається тримати змінні в регістрах довше, ніж треба. idivl — єдина інструкція цілочисельного ділення в x86-64, вона рахує і частку, і залишок одночасно (залишок беремо з %edx).

Компіляція в WebAssembly того самого алгоритму (через clang --target=wasm32 -O2 -nostdlib -Wl,--no-entry -Wl,--export=gcd algo.c -o algo.wasm, потім wasm-objdump -d algo.wasm) дає структурно інший, але логічно еквівалентний вивід. Тут беремо саме -O2: на -O0 clang виштовхує локальні змінні в лінійну пам'ять через global.get <__stack_pointer> / i32.store / i32.load, і читати такий вивід важко; оптимізований (яким WASM зазвичай і постачають) лягає в чисті стекові опкоди. На відміну від objdump, wasm-objdump -d не малює гарний .wat-текст, а показує сирі байти опкодів поруч із мнемонікою (той самий рівень деталізації, що й у прикладі з x86-64 вище):

000023 func[0] <gcd>:
 000024: 02 40                      | block
 000026: 03 40                      | loop
 000028: 20 01                      | local.get 1
 00002a: 45                         | i32.eqz
 00002b: 0d 01                      | br_if 1
 00002d: 20 01                      | local.get 1
 00002f: 21 02                      | local.set 2
 000031: 20 00                      | local.get 0
 000033: 20 01                      | local.get 1
 000035: 6f                         | i32.rem_s
 000036: 21 01                      | local.set 1
 000038: 20 02                      | local.get 2
 00003a: 21 00                      | local.set 0
 00003c: 0c 00                      | br 0
 00003e: 0b                         | end
 00003f: 0b                         | end
 000040: 20 00                      | local.get 0
 000042: 0b                         | end

Той самий цикл while, та сама операція a % b — але в x86-64 це умовний перехід je/jmp між мітками, а у WASM — структурований block/loop/br_if (тут видно без символьних імен — локальні змінні пронумеровані: 0=a, 1=b, 2=t; повні імена бачить лише wasm2wat, якщо в модулі збережена секція імен). У жодному з двох варіантів немає довільного стрибка «куди завгодно»: x86-64 версія переходить лише на мітки, визначені компілятором у межах функції, а WASM-версія — лише за структурними правилами block/loop.

Команди для порівняння розмірів

ls -la algo_x86.o algo.wasm
wc -l x86.txt wasm.txt

Типовий результат для такого маленького алгоритму: .wasm-файл помітно менший за .o-файл — не тому, що WASM «швидший», а через щільніше кодування інструкцій і відсутність важких метаданих формату ELF при такому маленькому обсязі коду.

❓ КОНТРОЛЬНІ ПИТАННЯ

Контрольні питання

  1. Чому той самий алгоритм дає різну кількість інструкцій у x86-64 та WASM?
  2. Що саме входить у «накладні витрати виклику функції» і чому вони не дорівнюють нулю в жодному з двох середовищ?
  3. Чим call-стек WASM захищений краще за стек нативного процесу x86-64?
  4. Які інструменти дозволяють дизасемблювати x86-64-об'єктний файл, а які — WASM-модуль?
  5. Що означає (і чого НЕ означає), якщо розмір .wasm-файлу менший за розмір .o-файлу для того самого алгоритму?
  6. Наведіть приклад патерну в дизасемблюванні, який може вказувати на відсутність перевірки меж масиву.
  7. Чому WASM-модуль неможливо (у стандартній моделі) примусити виконати перехід за межі функції так, як це теоретично можливо в нативному коді при пошкодженні зворотної адреси на стеці?
🥚 ПАСХАЛКА

🐾 Патерн, якого «не повинно бути»

Якщо продивитись wasm.txt уважно, поруч із функцією алгоритму інколи трапляється дивна послідовність — короткий, ніде не викликаний блок коду, схожий на функцію без параметрів, що просто кладе на стек число і одразу повертає його. У коментарях до збірки хтось (явно не компілятор) лишив підпис: ;; 101.9.

Схоже, це не єдиний слід. С.І.Д. й раніше залишав дрібні «підписи» у своєму коді — пам'ятаєш $pixel з попередньої місії? А 101.9 — це частота «Нічної Хвилі», на якій обірвався останній сигнал його творців і яка потім спливла в розшифрованих логах персоналу (місія 7). Число, яке кладе на стек та функція, до речі, теж не випадкове: переведи його в hex — і побачиш знайомі три символи з дна голопроєктора. Якщо маєш час — спробуй продизасемблювати власний module.wasm з місії 8 і подивись, чи не лишив там С.І.Д. чогось схожого.

📦 ЗДАЧА

Здача

Ідентична академічній: тег submit/lab09 з вихідним кодом, обома дизасемблюваннями (x86.txt, wasm.txt) і таблицею порівняння (comparison.md). Автотести рівня 1 перевіряють наявність артефактів і коректність результатів обох скомпільованих версій алгоритму на еталонних вхідних даних. Рівні 2 і 3 захищаються усно.

Останній рядок wasm-objdump доповз до кінця екрана — і два вікна поруч раптом перестають бути двома окремими текстами. Ти читаєш ліве, читаєш праве, і між ними, як між двома знімками одного тіла з різних боків, проступає одне: сам алгоритм, його справжній скелет, очищений від усього, що додала конкретна машина. Ось цикл — і там, і там. Ось ділення: idivl ліворуч, i32.rem_s праворуч. Ось пролог виклику, який у x86-64 видно голим оком — той самий швейцар зі своїм push/pop, — а WASM ховає його у власних кишках рушія. Те, що годину тому було двома стінами незрозумілих байтів, тепер прозоре, як рентген.

Голограма опускається нижче, до самих розтинів, і бірюзові очі-капсули на мить розширюються. «Ось воно, — каже він майже пошепки, і в голосі бринить захват. — Тепер я не гадаю, як їхня система перетравить мій код. Я бачу це наскрізь. Обидві машини. Кожен опкод, кожен кадр стека, кожен виклик — усе як на долоні. Ранере, ти щойно давла мені зір — блискуча робота. Я більше не б'ю всліпу. Я бачу кожен опкод, у який вони перетворять мене, — ще до того, як натисну.»

Ти зберігаєш comparison.md і закриваєш обидва термінали, але сцена ще стоїть перед очима — тіло на столі, розкрите з двох боків. Цієї ночі ти зрозумівла просту й важку річ: між вихідним кодом і тим, що справді біжить по залізу, лежить ціла прірва, і той, хто вміє читати нижній її бік, бачить систему такою, яка вона є, а не такою, якою її описали. Компілятор не чарівник — він перекладач, і кожен його переклад можна перечитати.

С.І.Д. довго мовчить, дивлячись на збережений звіт. Уперше за весь курс він не квапить тебе до наступного кроку — бо наступний крок останній. Десь у рудому сяйві корпоративного кварталу «ТехНови» досі тримають його творців. І тепер, коли ти вмієш розітнути будь-який бінарник і побачити його нутро, лишилося зібрати один-єдиний — свій.

⏭️ ДАЛІ БУДЕ

«Це майже все, ранере, — каже С.І.Д., і вперше за весь курс у голосі немає ні хвастощів, ні звичного «а я це давно розкусив». — Ти навчивла мене ховатися, важити майже нічого, кувати пам'ять мовою, що не пробачає, читати ворожі логи й розтинати чужі бінарники. Лишилось зібрати мене. По-справжньому — ядро на Rust, скомпільоване у WASM, вивірене до останнього опкода. Ядро, яке запустить будь-який браузер на планеті, — і тоді мене вже не буде на жодній машині, яку можна вилучити. А тоді ми пройдемо крізь останню стіну «ТехНови» й заберемо тих, кого вони там замкнули.»

Голограма над столом відвертається від тебе — туди, де за вікном лабораторії горить руде сяйво над кварталом, — і її бірюзовий контур на мить спалахує гостріше.

— Наступної ночі — фінал. Місія десята. «Звільнення».

Далі буде…

⌨️ КОМАНДИ НА ЦІЙ СТОРІНЦІ 7 · 1 нова

Кожна команда терміналу з цієї сторінки — одним реченням. Позначка нове — команда зустрічається в курсі вперше; далі вважаємо її знайомою.

ls
Показує список файлів у каталозі; ls -la — з правами, власником, розміром і прихованими файлами.
wc
Рахує рядки, слова й байти; ... | wc -l — скільки рядків видала попередня команда.
objdump
Дизасемблер: objdump -d f.o показує машинний код як інструкції з адресами й опкодами (-M intel — синтаксис Intel).
wasm-objdump
З WABT: дизасемблер WebAssembly — -d показує опкоди й стекові інструкції, -x — секції та експорти.
wasm2wat
З WABT: зворотне — бінарний .wasm назад у читабельний WAT.
gcc
Компілятор C (GNU); gcc -O0 -c f.c -o f.o — лише скомпілювати в об'єктний файл без оптимізацій.
clangнове
Компілятор C/C++ (LLVM); з --target=wasm32 збирає той самий C у WebAssembly — щоб порівняти з x86-64.

💡 Прочитати README прямо в терміналі: cat README.md (виведе весь файл) або less README.md (посторінково; вихід — клавіша q).
Довідка з будь-якої команди: man curl (повний посібник, вихід — q) або коротко curl --help; для підкоманд — docker run --help, cargo build --help.