Postup patchování:
- nahradit NOPy za INT3
- nahradit INT3 od konce za instrukci skoku
Má velký problém v případě, že přijde přerušení v momentě, kdy procesor zpracovává NOPy. Po návratu z přerušení pak bude pokračovat v polovině instrukce skoku.
Jak je zaručeno, že GCC vygeneruje 5x NOP a ne jeden pětibajtový NOP? Přepis části této instrukce na INT3 by mohlo způsobit pád.
Všechny běžně používané procesory umožňují atomicky zapsat 8 bytů - a určitě všechny používané v serverech.
Přišly by mi lepší dvě řešní:
a) namísto 5x NOP použít pětibajtový NOP a výměnu provést atomicky (pomocí cmpxchg8b; instrukce musí být v jedné cache line)
b) pokud cmpxchg8b není podporované (nějaká 80486 nebo Pentium či alternativy) a přesto patchování za běhu chcete, pak namísto 5x NOP použít skok s offsetem 0, a offset za běhu přepsat (způsobí flush pipeline a instrukce skoku musí být v jedné cache line)
Obě řešení předpokládají možnost atomického zápisu tím, že zapisovaný kód je v jedné cache line - to bych ale předpokládal, že bude vždy (funkce jsou běžně zarovnány na 16B). Nebo je právě tohle důvod, proč bylo zvoleno řešení s přepisem po bytech?
Má velký problém v případě, že přijde přerušení v momentě, kdy procesor zpracovává NOPy. Po návratu z přerušení pak bude pokračovat v polovině instrukce skoku.
Jak je zaručeno, že GCC vygeneruje ...
Bohužel jste narazil na to, že popis procesu výměny instrukcí byl pro účely přednášky/článku samozřejmě ochuzen, kvůli zjednodušení, o spoustu a spoustu detailů.
- NOPy negeneruje GCC, kernel si je do prologu funkcí přidává při bootu sám na místo, kam nechal původně gcc vygenerovat profilovací call (který se nepoužije).
- kernel používá (na x86_64) pětipajtový atomický nop ( ASM_NOP5_ATOMIC)
- dělá se vždy atomicky replace posledních čtyř bajtů za situace, kdy už je v prvním předem připraven INT3 bajtů opcode,
- pak se změní první bajt z INT3 na první bajt nového opcode
- mezitím se vždy dělá "magie", která zaručí, že CPU se "sesynchronizuje s realitou" (ve smyslu I$ a prefetchnutych instrukci), a to přes IRET-to-self. Důležité je to především po vložení INT3 do prvního bajtu, ale dělá se i mezi ostatními fázemi. Dle explicitního vyjádření Intelu jsou tyto ostatní synchronizace kromě té po vložení INT3 nadbytečné na Intel CPU, ale u AMD jsou pravděpodobně potřeba, proto je tam raději máme vždy.
namísto 5x NOP použít pětibajtový NOP a výměnu provést atomicky (pomocí cmpxchg8b; instrukce musí být v jedné cache line)
Tímhle ten procesor sesypete IMO asi celkem spolehlivě, protože vůbec nevíte, v jakém stavu byla I$ a kolik toho bylo prefetchnuto.
pak namísto 5x NOP použít skok s offsetem 0, a offset za běhu přepsat (způsobí flush pipeline a instrukce skoku musí být v jedné cache line)
Stejný argument jako výše -- výměna je atomická ve smyslu, že CPU + memory controller zajistí, že při vykonávání instrukci pro čtení z paměti je vždy vidět konzistentní hodnota. Vzhledem k I$ a pipeline toho atomicita příliš nezaručuje. Pokud v prvním bajtu pětibajtového NOPu nebudete mít INT3, tak se CPU může při takovéto výměně dostat do náhodného stavu.
Tím, že se ten breakpoint dá na začátek pětibajtového nopu (a tudíž CPU trapne korektně za kterékoliv situace) se zajistím bezproblémová výměna jak prvního, tak zbylých bajtů.
Díky za vysvětlení. V článku jsem se chytnul dvou věcí, které se mi nezdály:
- 5x NOP (namísto pětibytového nopu)
- "maximální atomický zápis 4 bytů"
- no a práce s patchováním na 0xcc a pak ošetřováním interruptu mi přišla zbytečně komplikovaná a potenciálně problematická, protože INT3 může být použit i jinak (při debugování)
Ad "NOPy negeneruje GCC" - znáte přepínač gcc -pg -mnop-mcount? Pak dostanete 5B NOPy už přímo z GCC, čímž si můžete ušetřit práci - pokud tedy současné chování není úmyslné.
Předpokládám, že tedy patchování probíhá následovně:
- přepis prvního byte na 0xCC
- IPI na všechny CPU, s tím, že musím počkat, než bude všude přijato (pro synchronizaci instrukčního dekodéru)
- pokud infrastruktura neumožňuje IPI sám sobě, provedu IRET ručně (jak píšete)
- přepsání offsetu
- znovu sync (kvůli AMD)
- přepsání 0xCC -> 0xE9
- znovu sync (kvůli AMD)
Je to takhle?
Byl jsem si celkem jistý, že instrukční dekodér vždy přečte zarovnaný 16B blok atomicky, proto by nemělo použití atomických operací při zarovnaném začátku vadit - ale můžu se pléct; i kdyby to takhle měl Intel, pořád jsou i další výrobci x86 procesorů.
Popsaný postup je tedy nakonec asi neprůstřelný.
Možná bych ale čekal mnohem jednodušší řešení - nový kernel slinkovat s .text na jinou adresu, a jen změnit entry pointy na nový kernel (to nejde udělat atomicky, ale v současném řešení to atomické taky není). Ostatní sekce (.data, .bss) musí být stejné, jinak by různé části kódu (nová a stará) používaly jiné adresy. Možná je nějaký zřejmý důvod, proč to nejde (např. některé adresy v kernelu jsou pevně dané). Co jsem přehlédl?
Díky
potenciálně problematická, protože INT3 může být použit i jinak (při debugování)
To máte pravdu, ale je to uděláno tak, že int3 exception handler může snadno detekovat zda-li se jedná o trap z debugovacího int3, nebo int3 kvůli live výměně instrukcí (protože víme, kde zrovna patchujeme), a podle toho se zachovat.
Ad "NOPy negeneruje GCC" - znáte přepínač gcc -pg -mnop-mcount? Pak dostanete 5B NOPy už přímo z GCC, čímž si můžete ušetřit práci - pokud tedy současné chování není úmyslné.
Ano, gcc má několik různých způsobů, jak vygenerovat NOPy do prologu. Je v tom docela nepořádek, některé z těch options jsou pouze pro některé architektury (např. ta kterou zmiňujete Vy pouze pro x86), nejsou kompatibilní s některými jinýmu důležitými options (např. opět Vámi zmiňovaná nemůže být použita společně s -fPIC). Mezi další (většinou opět arch-specific, ale pro jiné architektury) patří např. -mhotpatch, -mprofile-kernel, atd.
BTW pouze -pg nestačí, protože ten bohužel ten profilovací kód vygeneruje až za prolog, což už je dost pozdě. Je potřeba -mfentry.
V současné době se snažíme do gcc procpat obecnou option, která bude nezávislá na architektuře i čemkoliv ostatním, a bude generovat do prologu funkce potřebné (volitelné) množství NOPů.
I tak to ale budeme používat nadále jen pro rezervaci místa, a kernel si to bude při bootu přepatchovávat, protože se tam vždy dávají ideální / optimální NOPy pro dané CPU.
Předpokládám, že tedy patchování probíhá následovně:
Ano, popsal jsem to správně. Viz také changelog a komentáře commitu, kterým jsem tohle do kernelu přidával:
http://git.kernel.org/linus/fd4363fff3d96795d3feb1b3fb48ce590f186bdd
Možná bych ale čekal mnohem jednodušší řešení - nový kernel slinkovat s .text na jinou adresu, a jen změnit entry pointy na nový kernel
Tomu asi ne úplně přesně rozumím -- máte na mysli nahrát komplet celý .text celého vyměněného kernelu na nějaké jiné místo v paměti, a přesměrovat IDT/GDT, exception tables, apod?
Děkuji za upřesnění.
<i>Tomu asi ne úplně přesně rozumím -- máte na mysli nahrát komplet celý .text celého vyměněného kernelu na nějaké jiné místo v paměti, a přesměrovat IDT/GDT, exception tables, apod?</i>
Ano, tak jsem to myslel - byla by to první věc, kterou bych zkusil, kdybych měl za úkol vyměnit kernel za běhu. Pokud je možné vyměnit entry pointy s kGraft, pak by to mělo být možné i při výměně celé .text sekce. Ale dost možná bych narazil na nepřekonatelné problémy, které jsou mnohým zřejmé; linuxový kernel moc neznám. Nebo je řešení s kGraft jednoduše lepší.