65C02 – Keď sa zo sériového vstupu stane vykonateľný kód (code injection)
Minule som ukázal, ako sa dá prepísať návratová adresa na zásobníku a skočiť do vlastného kódu v RAM článok 65C02 – Prepísanie návratovej adresy (stack overflow). Na LCD vyskočilo PWNED! a na zbernici to bolo pekne vidno bajt po bajte. Mal to však jeden háčik: ten „zlý“ kód som mal stále vo firmvéri. Funkcia install_payload ho len skopírovala z EEPROM do $0200. Fungovalo to na učenie stacku, ale ešte to nebolo to, čo ľudia myslia pod code injection.
Na konci som sľúbil, že payload nemusí byť v ROM ale že sa dá do RAM natlačiť ako vstup a spustiť. Tento diel sa na to pozrieme. Shellcode mi príde z notebooku cez sériový kábel, program ho zapíše do pamäte, ten istý overflow ako v minulom článku prepne riadenie, a znova uvidím PWNED!. V EEPROM už žiadny payload_src nie je. To, čo sa spustí, prišlo zvonku.
O čo ide
Stále mám zámerne zlý program. Stále má funkciu s 8-bajtovým bufferom na stacku, ktorá načíta 10 bajtov bez kontroly dĺžky. Stále na konci vstupu podvrhnem FF 01, aby RTS skočilo na $0200. To všetko už bolo vysvetlené a ak si to nečítal, začni dielom 1.
Rozdiel je v tom, odkiaľ sa na $0200 vôbec niečo dostane. Minule to bol oficiálny kúsok firmvéru. Teraz program po štarte ukáže Wait..., chvíľu počká, vypíše Normal... a čaká na sériový vstup: najprv 29 bajtov shellcodu do RAM, potom 10 bajtov overflowu. Keď ich stihnem poslať, RTS skočí do kódu z kábla. Keď nepríde nič, na LCD príde Done ako keby sa program „normálne“ skončil.
Inými slovami: najprv je vidieť pokojný beh, potom mám čas na vstreknutie, a buď PWNED!, alebo Done.
Čo som doplnil na breadboard
Základ je stále ten istý stroj: 65C02, EEPROM, RAM, VIA na $6000, LCD… Aby som mohol posielať bajty z notebooku, musel som urobiť pár úprav podľa Ben Eater sérii so sériovým portom.
Dal som na dosku W65C51N ACIA, teda čip, ktorý vie sériovú linku. U mňa sedí na $5000–$5003, ako to má Ben. K nej vlastný kameň 1.8432 MHz, lebo baud rate generátor v ACIA potrebuje svoje hodiny; to nie je to isté ako systémový clock CPU. CPU u mňa tiež beží z 1.8432 MHz, LCD čítam cez busy flag, takže rýchlejší takt nie je problém.
Medzi ACIA a vonkajším svetom je MAX232N, DB9, a do laptopu USB–RS-232 prevodník. Na ACIA som musel dať /DCD a /DSR na zem keď plávali, status register síce žil, ale znaky z PC neprichádzali. To ma chvíľu trápilo, kým som to doladil.
Terminál aj skript idem na 19200 8N1, bez flow controlu. Dôležitá poznámka: serial u mňa nenahráva EEPROM. ROM stále pečiem Arduino programátorom. Kábel z PC len kŕmi bežiaci program, dáta idú do RAM a neskôr na stack, nie do flash/EEPROM čipu.


Ako vyzerá čítanie zo serialu
ACIA má registre na dátach, statuse, command a control. Po resete ju nastavím na 19200, 8 bitov, bez parity, bez interruptov:
lda #$00
sta ACIA_STATUS ; soft reset
lda #$0b
sta ACIA_COMMAND ; bez parity, bez IRQ
lda #$1f
sta ACIA_CONTROL ; 8N1, 19200 (s 1.8432 MHz)
Po Normal... čakám na prvý bajt s timeoutom ak nepríde, idem na Done. Len čo príde prvý bajt, zvyšok už čítam blokujúco. Vstrekovanie do RAM je prakticky starý install_payload, len namiesto EEPROM volám serial:
ldx #0
jsr get_byte_to ; prvý bajt — C=1 → Done
bcs attack_fail
sta $0200,x
inx
inj_loop:
jsr get_byte ; zvyšok bez timeoutu (NEMENÍ X!)
sta $0200,x
inx
cpx #SHELL_LEN ; 29
bne inj_loop
Tu som sa aj sekol: v get_byte_to som mal oneskorovaciu slučku cez register X, ktorý je zároveň index do $0200. Pri čakaní na bajt sa index stále nuloval, shellcode sa nikdy nedoplnil a program skončil na Done…. Klasika na 6502 si treba strážiť, ktorý register práve „patrí“ čomu.
Čo posielam z PC
Celý „útok“ je jedna dávka 39 bajtov. Prvých 29 je shellcode ten istý nápad ako minule: vyčisti LCD, vypíš PWNED!, zasekni sa. Volá moje rutiny lcd_instruction a print_char v EEPROM, takže v shellcode musia sedieť skutočné adresy po assemble. U mňa vyšlo $8143 a $8159. Ďalších 10 bajtov je overflow do vulnerable: osem písmen výplne a podvrhnutý návrat. Háčik s JSR/RTS som už raz rozoberal chcem $0200, tak na stack ide $01FF ako bajty FF 01.
Takto to vyzerá ako assembler (presne to isté posiela send_payload.py):
; --- 29 B shellcode → CPU to uloží na $0200 ---
.byte $A9, $01 ; LDA #$01 clear LCD
.byte $20, $43, $81 ; JSR $8143 lcd_instruction
.byte $A2, $00 ; LDX #0
.byte $BD, $16, $02 ; LDA $0216,X znak z "PWNED!"
.byte $F0, $07 ; BEQ hang
.byte $20, $59, $81 ; JSR $8159 print_char
.byte $E8 ; INX
.byte $4C, $07, $02 ; JMP $0207 ďalší znak
.byte $4C, $13, $02 ; hang: JMP hang
.byte $50,$57,$4E,$45,$44,$21,$00 ; "PWNED!"
; --- 10 B overflow → buffer + podvrh návratu ---
.byte $41,$42,$43,$44,$45,$46,$47,$48 ; pad "ABCDEFGH"
.byte $FF, $01 ; návrat−1 → po RTS $0200
Do PuTTY by som to ručne nabíjal nerád. Tam sú nuly, FF a podobné veci, ktoré terminál rád pokazí. Preto mám malý skript send_payload.py: otvorí COM na 19200, počkám kým LCD ukáže Normal..., stlačím Enter, a skript pošle tých 39 bajtov.

Čo uvidím na LCD
Po resete najprv svieti Wait.... Asi tri sekundy sa nič nedeje (serial v tejto fáze zámerne neberiem). Potom displej vyčistím a vypíšem Normal.... Od tej chvíle čakám na začiatok útoku (rádovo pár sekúnd): ak stihnem poslať payload, overflow prepne riadenie, shellcode znova vyčistí LCD a ostane PWNED!. Ak nepríde ani prvý bajt, program vypíše Done.
Bez útoku teda na videu uvidíš postupne Wait... → Normal... → Done. S útokom Wait... → Normal... → PWNED!.
Prečo to zase prejde
Lebo na tomto stroji stále nie je nič, čo by oddelilo „dáta zo serialu“ od „kódu, ktorý sa smie spustiť“. ACIA mi do procesora podáva bajty. Program ich uloží na $0200. Overflow mi podhodí návrat. RTS uverí stacku. CPU začne fetchovať z RAM a vykoná, čo tam nájde aj keď to pred chvíľou prišlo ako „vstup“.
Minule som hovoril o NX, canary a limitoch dĺžky. Platí to aj tu. Jediný nový moment je psychologický: payload už nie je „môj program v ROM“, ale niečo, čo som poslal zvonku. Technicky je to stále ten istý 8-bitový svet bez ochrany. Len útok vyzerá oveľa bližšie k tomu, čo si človek predstaví pri code injection.
Čo s tým
Na úrovni tohto dema je odpoveď stále tá istá ako minule: nekopírovať do buffra viac, než sa doň zmestí. Overflow sú dvere; vstrekovanie je to, čo cez ne vnesieš. Keď dvere zamkneš, shellcode z kábla sa na $0200 síce môže uložiť (ak mu to program dovolí), ale bez prepisu návratu sa nespustí.
Pri code injection treba myslieť ešte o krok ďalej. Vstup zo serialu by sa mal správať ako dáta, nie ako miesto, odkiaľ CPU neskôr fetchuje. Na nových CPU to rieši NX / W^X pamäť je buď zapisovateľná, alebo spustiteľná, nie oboje naraz. Na holom 65C02 to nemám; všetko, čo je v RAM, sa môže vykonať, ak na to skočím. Preto je dobrý zvyk: buffre mimo stacku, jasný limit dĺžky, a ak už niečo zo vstupu skladám do pamäte, nemal by som tam zároveň skákať cez podvrhnuté RTS.
V ďalšom diele chcem ísť ešte o kúsok ďalej: nepotrebovať vlastný shellcode v RAM. Stačí ukradnúť riadenie a skočiť do niečoho, čo už v programe je.. existujúce rutiny, „gadgety“, prevzatie toku namiesto vstrekovania celého payloadu. To už je bližšie k tomu, čo sa deje, keď NX shellcode zablokuje, ale návratovú adresu stále vieš ohnúť.