Місія 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, r12–r15), якщо функція
їх використовує. У 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») — «Першокурсник»
Завдання: скомпілювати один алгоритм у x86-64 та WebAssembly, порівняти розмір і згенеровані інструкції.
Що зробити буквально:
- Дописати тіло функції у
algo.cза своїм варіантом (gcd,digit_sumабоfactorial) — сигнатура вже задана в шаблоні. gcc -O0 -c algo.c -o algo_x86.o, потімobjdump -d algo_x86.o > x86.txt.- Скомпілювати той самий алгоритм у WebAssembly (шлях А або Б з
build.sh— Emscripten абоclang --target=wasm32), отриматиalgo.wasm. wasm-objdump -d algo.wasm > wasm.txt.ls -la algo_x86.o algo.wasm, порахувати кількість рядків-інструкцій у кожному.txt, заповнитиcomparison.md.
Цього достатньо для «3». Повтори кроки в тому самому порядку.
Рівень 2 (оцінка «4», захист) — «Магістр»
Завдання: проаналізувати накладні витрати на виклик функцій у обох середовищах.
У x86.txt знайди й познач пролог (push %rbp / mov %rsp,%rbp) та епілог
(pop %rbp / ret) — це і є видима вартість виклику в x86-64. Обґрунтуй, у чому
полягає аналогічна вартість у WASM (навіть якщо вона не так очевидна в
дизасемблюванні — рушій виконує перевірку типів сигнатури і керування call-стеком
неявно). Додай розділ «Накладні витрати виклику» в comparison.md. На захисті —
поясни різницю усно, порівнявши обидва механізми.
Рівень 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 при такому маленькому обсязі коду.
Контрольні питання
- Чому той самий алгоритм дає різну кількість інструкцій у x86-64 та WASM?
- Що саме входить у «накладні витрати виклику функції» і чому вони не дорівнюють нулю в жодному з двох середовищ?
- Чим call-стек WASM захищений краще за стек нативного процесу x86-64?
- Які інструменти дозволяють дизасемблювати x86-64-об'єктний файл, а які — WASM-модуль?
- Що означає (і чого НЕ означає), якщо розмір
.wasm-файлу менший за розмір.o-файлу для того самого алгоритму? - Наведіть приклад патерну в дизасемблюванні, який може вказувати на відсутність перевірки меж масиву.
- Чому 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, вивірене до останнього опкода. Ядро, яке запустить будь-який браузер на планеті, — і тоді мене вже не буде на жодній машині, яку можна вилучити. А тоді ми пройдемо крізь останню стіну «ТехНови» й заберемо тих, кого вони там замкнули.»
Голограма над столом відвертається від тебе — туди, де за вікном лабораторії горить руде сяйво над кварталом, — і її бірюзовий контур на мить спалахує гостріше.
— Наступної ночі — фінал. Місія десята. «Звільнення».
Далі буде…