14.1 Verem-túlcsordulás mechanizmusa assembly szinten

14.1.1 Motiváció: a C-ben elkövetett hiba assembly szinten válik láthatóvá

A határellenőrzés nélküli másolás a C-programok egyik legrégebbi és legveszélyesebb hibaosztálya. A strcpy, a gets vagy a sprintf korlátlanul ír, ha a célpuffer mérete nem elegendő. A C-fejlesztő számára ez „csak” egy memóriahiba; assembly szinten viszont pontosan látszik, hogy a másolás a verem szomszédos tartalmát írja felül. Ebben az alfejezetben kontrollált demonstrációval megmutatjuk, hogyan íródik felül a mentett visszatérési cím (return address), és miért lesz ebből vezérlésátvétel. A cél nem egy működő exploit elkészítése, hanem a mechanizmus és a védelem megértése. A példa a saját, jelölt labor-binárisunkra épül, fix bemenettel, és a túlcsordulást még a RET előtt detektálja.

14.1.2 Definíció: verem-túlcsordulás

Verem-túlcsordulás (stack buffer overflow): olyan írási művelet, amely egy verembeli puffer határán túlra ír, és ezzel a szomszédos veremtartalmat — lokális változókat, mentett keretmutatót (frame pointer, FP) vagy mentett visszatérési címet (link register, LR) — módosítja. Nem azonos a veremkimerüléssel (stack exhaustion), amikor végtelen rekurzió miatt elfogy a veremterület. Itt a verem mérete elegendő, de egy másolás túlnyúlik a kijelölt pufferen.

Az AAPCS64 szerinti verem lefelé növekszik: a függvény az SP-t kisebb címre állítja. Ebben a példában a keret alján (alacsonyabb címen) helyezkedik el a lokális puffer, felette a mentett X29/X30 pár. Egy felfelé, növekvő címre író másolás először a puffert, majd a mentett FP-t, végül a mentett LR-t éri el. Pontosan ez a sebezhető elrendezés.

Veremterület Offset az SP-hez képest Tartalom
Lokális puffer 0–31 32 bájtos tömb
Mentett X29 (FP) 32–39 hívó keretmutatója
Mentett X30 (LR) 40–47 visszatérési cím
Kihasználatlan igazítás 48–63 16 bájtos igazítás

A táblázat a példabeli 64 bájtos keretre vonatkozik. A konkrét elrendezés fordító- és optimalizálási szintfüggő; a lényeg, hogy a puffer és a mentett LR között nincs hardveres határ.

14.1.3 Hogyan lesz a túlírásból felülírás?

A másoló ciklus minden bájtot egymás után ír a puffer kezdetétől. A 0-tól számozott indexek közül a 32. már a mentett X29, a 40. a mentett X30 első bájtját éri el: ehhez rendre 33, illetve 41 bájtos másolás kell. A szokásos epilógus az LDP utasítással tölti vissza a mentett X30-at; a RET ezután a regiszterben lévő címre ugrik. Ha a felülírt mentett értéket ellenőrzés nélkül töltenénk vissza, a RET nem a hívóba térne vissza, hanem az így kapott címre. Ez a vezérlésátvétel (control-flow hijack) fogalmi szintje: a támadó által befolyásolt érték a program számlálójába (PC) kerül. A példában ezt nem érjük el, mert a RET előtt ellenőrizzük a mentett LR épségét, és szándékosan leállítjuk a programot.

A mentett FP felülírása önmagában nem ad vezérlést, de a láncolt keretlistát és a hibakeresést megzavarja. A lokális változók sérülése pedig akár a másolás méretét vagy egy mutatót is módosíthat, ami további hibákhoz vezet.

14.1.4 Kidolgozott példa: kontrollált túlcsordulás A64 assembly-ben

Munkakönyvtár: ~/arm-labor/a64/a053/ — az alfejezet minden laborfájlja ide kerül, és minden parancs innen futtatandó (a könyvtárrendet a Labor-alapkörnyezet alfejezet írja le).

mkdir -p ~/arm-labor/a64/a053
cd ~/arm-labor/a64/a053

Az alábbi lab-overflow.S egyetlen fájl, amely _start belépési ponttal fut. CFI nélküli demonstráció: a lab_vuln_copy keretet épít, de szándékosan nem kap .cfi_* direktívákat — a vizsgálat tárgya éppen a veremben lévő keretrekord, ezért a hívási lánc itt csak a keretlánc (X29) alapján, és a felülírás után már úgy sem követhető (a CFI-szabályt lásd a Prológus és epilógus alfejezetben). A lab_vuln_copy függvény 32 bájtos puffert feltételez, de a hosszt nem ellenőrzi. A forrás 48 bájt: 32 bájt minta, 8 bájt a mentett FP helyére, 8 bájt hamis LR érték. A hamis LR (0xdeadbeefcafebabe) szándékosan nem érvényes cím, és soha nem hajtódik végre, mert a detektálás megelőzi a RET-et.

// lab-overflow.S — A64 ISA, AArch64, little-endian
    .section .data
src:
    .ascii "AAAAAAAAAAAAAAAA"   // 16 bájt
    .ascii "BBBBBBBBBBBBBBBB"   // 16 bájt -> puffer 32 bájt
    .quad  0x1122334455667788   // mentett X29 felülírása
    .quad  0xdeadbeefcafebabe   // mentett X30 felülírása
src_end:
    .equ SRC_LEN, src_end - src // 48

    .section .text
    .global _start
_start:
    adr x0, src
    mov x1, #SRC_LEN
    bl  lab_vuln_copy
    // Ide normál esetben vissza kellene térni.
    mov x0, #0
    mov x8, #93          // exit
    svc #0

// x0 = forrás, x1 = hossz
lab_vuln_copy:
    sub sp, sp, #64
    stp x29, x30, [sp, #32]   // mentett FP/LR a puffer felett
    add x29, sp, #32
    mov x2, #0
1:
    cmp x2, x1
    b.ge 2f
    ldrb w3, [x0, x2]
    strb w3, [sp, x2]         // nincs határellenőrzés
    add x2, x2, #1
    b   1b
2:
    // Defenzív ellenőrzés: a mentett LR érintetlen?
    ldr x3, [x29, #8]         // mentett X30
    cmp x3, x30               // X30 = eredeti visszatérési cím
    b.ne lab_overflow_detected
    ldp x29, x30, [sp, #32]
    add sp, sp, #64
    ret

lab_overflow_detected:
    mov x0, #1                // jelzett kilépési kód
    mov x8, #93
    svc #0

    .section .note.GNU-stack,"",%progbits   // nem kér végrehajtható vermet

Build és futtatás (qemu-user, AArch64):

aarch64-linux-gnu-as -o lab-overflow.o lab-overflow.S
aarch64-linux-gnu-ld -o lab-overflow lab-overflow.o
readelf -lW lab-overflow | grep GNU_STACK   # elvárt jelző: RW
qemu-aarch64 ./lab-overflow
echo $?

A GNU_STACK ellenőrzése a kész ELF-re vonatkozik. A forrásbeli direktíva a szándékot rögzíti, de egy hiányzó direktívából önmagában nem következik végrehajtható verem: hogy a linker jelzés nélküli objektumból mit állít elő, az eszközlánc- és verziófüggő.

Várható kimenet: a program az lab_overflow_detected ágra kerül, ezért a kilépési kód 1. A 0xdeadbeefcafebabe hamis LR nem hajtódik végre. A qemu-aarch64 itt statikus binárist futtat, külső -L rendszerkönyvtár nélkül.

Ellenőrzés GDB-vel (gdb-multiarch):

qemu-aarch64 -g 1234 ./lab-overflow  # az egyik terminálban
# A másik terminálban:
gdb-multiarch -q ./lab-overflow
(gdb) set architecture aarch64
(gdb) break lab_overflow_detected
(gdb) target remote localhost:1234
(gdb) continue
(gdb) info registers x29 x30 sp
(gdb) x/8gx $sp

A töréspontnál a sp a keret aljára mutat. A [sp+32] és [sp+40] tartományban a felülírt mentett FP és LR látható. A $x30 ekkor még az eredeti visszatérési cím, a [x29, #8] pedig a hamis érték; a cmp mutatja az eltérést.

14.1.5 Kapcsolat a C-s buffer overflow hibaosztállyal

C-ben a tipikus minta:

void f(const char *input) {
    char buf[32];
    strcpy(buf, input);   // nincs méretellenőrzés
}

A strcpy addig másol, amíg nullát nem lát. Ha az input hosszabb 31 bájtnál, ugyanaz a veremfelülírás történik, mint a példában. A -fstack-protector-strong kapcsolóval a fordító a védelemre kijelölt, ténylegesen verembeli objektumot tartalmazó függvényekben canary (stack canary) értéket helyez el; a függvény epilógusa ellenőrzi annak épségét. A tényleges generált kódot kell vizsgálni: az optimalizálás a fel nem használt lokális puffert el is tüntetheti. A _FORTIFY_SOURCE megfelelő fordítási feltételek és ismert célobjektumméret mellett ellenőrzött változatot, például __strcpy_chk-t rendelhet a strcpy hívásához. Ezek a védelmek a Védelmi mechanizmusok: canary, ASLR, NX/DEP, PAC alfejezetben részletesen szerepelnek. A mostani példa canary nélküli, ezért a mentett LR közvetlenül elérhető.

14.1.6 Tipikus hibák és ellenpróbák

Ellenpróbák a példán (a _start mov x1, #SRC_LEN sorát mov x1, #32-re, illetve #40-re cserélve): 32 bájtos bemenet → nincs túlcsordulás, a program 0 kóddal lép ki. 40 bájtos bemenet → a mentett FP felülíródik, de a mentett LR nem; a detektálás nem jelez, a ret a helyes címre tér vissza, de a X29 hamis. 48 bájtos bemenet → a mentett LR is felülíródik; a detektálás 1 kóddal leáll.

14.1.7 Feladatok

  1. (magyarázó, alapszint — cél: megértés-ellenőrzés) Az alapdemonstráció 64 bájtos keretében adja meg a puffer, a mentett X29 és a mentett X30 offsetjét az SP-hez képest. Melyik másolási hossznál íródik felül a mentett LR első bájtja?
  2. (implementációs, középszint — cél: rutinépítés) Készítse el az alapdemonstráció canary-val kiegészített változatát lab-overflow-canary.S néven. A lab_vuln_copy a puffer utáni 32–39. offsetre írja ki a 0x0123456789abcdef értéket a másolás előtt, majd a másolás után hasonlítsa össze az eredeti értékkel. Eltéréskor a lab_overflow_detected út állítsa le a programot. Adja meg a teljes keretelrendezést, az X29 képzését, a mentett FP/LR helyét és a sikeres epilógust is. Az eredeti 48 bájtos forrás 0, 32, 33, 40 és 48 bájtos prefixével végezzen ellenpróbát: az első két eset térjen vissza, a többi jelezzen canary-sérülést. A hívó a sikeres visszatérés után külön ellenőrizze az SP és az X29 eredeti értékét.
  3. (hibakeresési/mérési, középszint — cél: rutinépítés) Az eredeti, canary nélküli lab-overflow.S programban GDB-vel állítson töréspontot a strb utasításra. Figyelje az x2 regisztert és a [sp, x2] címet. Melyik iterációnál íródik felül a mentett LR? Adja meg az x2 értékét és a strb által írt bájt címét.

14.1.8 Megoldások

1. megoldás — Segítség. Nézd meg a fejezet eleji offset-táblázatot, és gondold át: a másolás a 0. indextől indul — hány bájtot kell írni ahhoz, hogy az index elérje a SP+40-et?

1. megoldás — Teljes megoldás. Puffer: SP+0–SP+31. Mentett X29: SP+32–SP+39. Mentett X30: SP+40–SP+47. A mentett LR első bájtja a SP+40 címen van. Mivel a másolás a 0. indextől indul, a 40. indexű bájt írásához 41 bájtos másolás szükséges. A 40 bájtos másolás a 0–39. indexeket fedi, így a SP+40-et még nem éri el.

2. megoldás — Segítség. A canary a puffer és a mentett FP/LR közé kerüljön. Előbb rajzold fel a teljes keretet, majd egyeztesd vele a mentést, az X29 értékét és a visszatöltést. Mikor kell a jelzőértéket a verembe írni? Mely regiszter őrizheti az összehasonlítás referenciáját, ha a másoló ciklus nem hív más függvényt?

2. megoldás — Teljes megoldás. Az alábbi önálló, teljes programot mentsd lab-overflow-canary.S néven. Az alapdemonstráció forrásadatait használja, és a canary alapján dönt; az eredeti lab-overflow.S változatlanul megtartható összehasonlításra.

CFI nélküli demonstráció: az alapváltozathoz hasonlóan a lab_vuln_copy itt is keretet épít .cfi_* direktívák nélkül, mert a vizsgálat tárgya maga a verembeli keretrekord (a CFI-szabályt lásd a Prológus és epilógus alfejezetben). A választott keret 80 bájtos, így az SP 16 bájtos igazítása megmarad. A függvény törzsében az offsetek a keret lefoglalása utáni SP-hez képest értendők:

Offset Tartalom
0–31 32 bájtos puffer
32–39 Canary: 0x0123456789abcdef
40–47 Kitöltés
48–55 Mentett X29
56–63 Mentett X30
64–79 Kitöltés

Az X29 = SP + 48 a mentett FP/LR párra mutat. A canary első bájtját 33, az új helyen lévő mentett FP első bájtját 49, a mentett LR első bájtját 57 bájtos másolás érné el. A mostani 48 bájtos forrás tehát a canary-t és az utána álló kitöltést írja felül, a feljebb helyezett FP/LR párt még nem éri el.

// lab-overflow-canary.S — A64 ISA, AArch64 Linux, little-endian
    .section .data
src:
    .ascii "AAAAAAAAAAAAAAAA"
    .ascii "BBBBBBBBBBBBBBBB"
    .quad  0x1122334455667788
    .quad  0xdeadbeefcafebabe
src_end:
    .equ SRC_LEN, src_end - src       // 48 bájt, mint az alapdemóban

    // A --defsym COPY_LEN=N az N bájtos prefixet választja ki.
    .ifndef COPY_LEN
    .equ COPY_LEN, SRC_LEN
    .endif
    .if COPY_LEN < 0
    .error "COPY_LEN must be nonnegative"
    .endif
    .if COPY_LEN > SRC_LEN
    .error "COPY_LEN exceeds source size"
    .endif

    .section .text
    .global _start
_start:
    // Hívói keret: érvényes, nullával lezárt keretrekord.
    sub sp, sp, #16
    stp xzr, xzr, [sp]
    mov x29, sp
    mov x19, sp                      // elvárt SP és X29; a hívott nem írja
    adr x0, src
    mov x1, #COPY_LEN
    bl  lab_vuln_copy

    // Csak a sikeres visszatérési út jut ide.
    mov x4, sp
    cmp x4, x19
    b.ne lab_frame_error
    cmp x29, x19
    b.ne lab_frame_error
    add sp, sp, #16                  // a hívó saját keretének felszabadítása
    mov x0, #0
    b   lab_exit

// x0 = forrás, x1 = hossz; a laborban 0 <= x1 <= SRC_LEN.
// A másolás szándékosan nem vizsgálja a 32 bájtos pufferhatárt.
lab_vuln_copy:
    sub sp, sp, #80
    stp x29, x30, [sp, #48]
    add x29, sp, #48

    movz x6, #0xcdef
    movk x6, #0x89ab, lsl #16
    movk x6, #0x4567, lsl #32
    movk x6, #0x0123, lsl #48
    str x6, [sp, #32]                // inicializálás a másolás ELŐTT

    mov x2, #0
1:
    cmp x2, x1
    b.hs 2f                         // hossz és index: előjel nélküli
    ldrb w3, [x0, x2]
    strb w3, [sp, x2]
    add x2, x2, #1
    b   1b
2:
    ldr x5, [sp, #32]
    cmp x5, x6                      // x6-ot a ciklus nem módosította
    b.ne lab_overflow_detected
    add x7, sp, #48
    cmp x29, x7                     // az X29 a saját keretrekordra mutat?
    b.ne lab_frame_error
    ldp x29, x30, [sp, #48]
    add sp, sp, #80
    ret

lab_overflow_detected:
    mov x0, #1                      // a canary megváltozott
    b   lab_exit
lab_frame_error:
    mov x0, #2                      // hibás X29-képzés, vagy SP/X29 nem állt helyre
lab_exit:
    mov x8, #93                     // Linux SYS_exit; nem tér vissza
    svc #0

    .section .note.GNU-stack,"",%progbits

Az X6 a 0x0123456789abcdef referenciaértéket tartja, a STR ugyanezt írja a guard helyére. Little-endian memóriában a bájtsor ef cd ab 89 67 45 23 01. A ciklus csak az X2 és W3 regisztert módosítja, nem hív más függvényt, ezért az X6-ban lévő referencia megmarad. Ha a rutin később hívásokat kap, az X6 caller-saved szerepe miatt a referencia megőrzését újra kell tervezni.

A sikeres út előbb a hívott oldalán ellenőrzi, hogy az X29 valóban a saját keretrekordjára (SP + 48) mutat; ez a képzést vizsgálja. Ezután az epilógus a párt a 48-as offsetről tölti vissza, és 80 bájtot ad az SP-hez. A hívó az X19-ben megőrzött címhez hasonlítja az SP-t és az X29-et; ez a visszaállítást vizsgálja. Bármelyik eltérésnél 2 a program kilépési állapota. Canary-sérüléskor 1 az állapot: ez az ág befejezi az egész demonstrációt, ezért nem hajt végre epilógust és RET-et.

Build és egy futás (GNU eszközlánc + QEMU):

aarch64-linux-gnu-as -o lab-overflow-canary.o lab-overflow-canary.S
aarch64-linux-gnu-ld -o lab-overflow-canary lab-overflow-canary.o
readelf -lW lab-overflow-canary | grep GNU_STACK   # elvárt: RW
# Az alapértelmezett COPY_LEN = 48 esetben 1 az elvárt állapot.
if qemu-aarch64 ./lab-overflow-canary; then rc=0; else rc=$?; fi
printf 'exit=%s\n' "$rc"

Önellenőrzés az öt bemenetre (bash): a --defsym csak a másolási hosszt változtatja; a forrásadat és a függvény ugyanaz marad. Az if az elvárt 1 állapotot is kezeli akkor, ha a shellben a set -e be van kapcsolva. A ciklus alhéjban fut, így az exit 1 hibánál csak az alhéjat állítja le, interaktív terminálban sem zárja be a shellt.

(
for n in 0 32 33 40 48; do
    aarch64-linux-gnu-as --defsym "COPY_LEN=$n" \
        -o lab-overflow-canary.o lab-overflow-canary.S || exit 1
    aarch64-linux-gnu-ld -o lab-overflow-canary \
        lab-overflow-canary.o || exit 1
    if qemu-aarch64 ./lab-overflow-canary; then rc=0; else rc=$?; fi
    if [ "$n" -le 32 ]; then want=0; else want=1; fi
    printf 'n=%s exit=%s elvart=%s\n' "$n" "$rc" "$want"
    [ "$rc" -eq "$want" ] || exit 1
done
)

Elvárt eredmény:

n=0 exit=0 elvart=0
n=32 exit=0 elvart=0
n=33 exit=1 elvart=1
n=40 exit=1 elvart=1
n=48 exit=1 elvart=1

A 33 bájtos prefix utolsó bájtja a forrásban 0x88, amely az SP+32 címen lévő 0xef canary-bájtot változtatja meg. A sérülés így nem a verem korábbi tartalmától függ. A 0 és 32 bájtos futás csak akkor ad 0-t, ha a jelző sértetlen, és az SP/X29 is helyreállt.

Ez a fordítói stack protector alapelvét szemlélteti: a mentett vezérlési adatok felhasználása előtt ellenőrizzük a közéjük és a puffer közé tett jelzőt. A canary nem akadályozza meg a túlírást, és az egyezése nem bizonyítja a másolás szabályosságát. A fix, nyilvános érték determinisztikus oktatási jelző: azért választottuk, hogy a próba ismételhető legyen, és változatlan bájtokkal felül is lehet írni. A valódi védelemhez megfelelő fenyegetési modell, rendszerint kiszámíthatatlan guard és a biztonságos bemenethossz-kezelés is szükséges; a mostani ellenpróbák a guardot biztosan megváltoztató mintát használják.

3. megoldás — Segítség. A ciklus x2-t 0-tól indexeli; ugyanazzal a logikával, mint az 1. feladatban, keresd meg, melyik x2 érték esik egybe a mentett LR első bájtjának offsetjével.

3. megoldás — Teljes megoldás. A strb már az első iterációban (x2 = 0) ír, és minden iterációban pontosan egy bájtot. A mentett LR a [sp+40] címen kezdődik. Az x2 = 40 értéknél a strb w3, [sp, x2] a [sp+40] címre ír, ez az első bájt, amely a mentett X30-at érinti. Mivel az index 0-tól indul, ez a 41. végrehajtott írás: a GDB-ben a 41. iteráció után változik meg a [sp+40] tartalma. A detektálás a másolás után, a 2: címkénél fut, és a cmp jelzi az eltérést.

14.1.9 Rövid összefoglaló

A verem-túlcsordulás akkor jön létre, ha egy határellenőrzés nélküli másolás túlnyúlik a pufferen, és felülírja a szomszédos veremtartalmat. A mentett visszatérési cím a RET miatt kitüntetett célpont: felülírása vezérlésátvételhez vezethet. A példa ezt kontrolláltan, detektálással állítja meg a RET előtt. A C-s strcpy-hez hasonló hiba ugyanezt a mechanizmust idézi elő; a canary, a _FORTIFY_SOURCE és a fordítói védelmek ezt hivatottak kivédeni.

14.1.10 Források és ellenőrzési hiányok

A linkek és a dokumentumverziók közös állapota az Olvasói útmutató forrásállapot-táblájában van; itt csak az alfejezet saját megállapításai szerepelnek.

A canary-megoldás ellenőrzési állapota (2026-10-20). A fenti teljes lab-overflow-canary.S programot a helyettesítő eszközlánccal (zig cc 0.17.0: Clang 22.1.8 + LLD) fordítottuk, és a tools/lab-run.py 1.1 futtatóban (Unicorn 2.1.4) ellenőriztük; a próba a kísérőcsomag tests/run.sh készletének része (tools/a053/). A 0/32/33/40/48 bájtos esetek állapota rendre 0/0/1/1/1 volt. A sikeres utakon a program saját SP/X29-vizsgálata is teljesült. A guard inicializáló tárolásának elhagyása a sértetlen bemenetet is hibára vitte; a sikeres epilógus hibás, 64 bájtos SP-felszabadítása és a hibás, SP + 32-ből képzett X29 egyaránt 2 állapotot adott. Ezek a hibás kontrollok a teszt érzékenységét ellenőrzik. A helyettesítő futtató eredménye funkcionális bizonyíték, nem QEMU- vagy hardverfuttatás.

Fennmaradó ellenőrzések: a fenti GNU assembler/linker + qemu-aarch64 parancsokat és a GDB-lépéseket a fő laborúton is végre kell hajtani. A két kézzel írt demonstráció offsetjeit a saját utasításaik rögzítik: az alapváltozat kerete 64, a canary-változaté 80 bájt. Egy C-fordító által létrehozott keret elrendezése ettől függetlenül fordító- és optimalizálásfüggő. A fordítói stack protector és a glibc fortifikációja külön mechanizmus; konkrét támogatásuk és generált kódjuk a labor eszközverzióival ellenőrizendő.