Yksi mielenkiintoisimmista ja yleisimmin käytetyistä konsepteista x86-arkkitehtuurissa on Suojattu tila ja sen tuki neljässä tilassa (eli renkaissa):
Tämän ajatuksen hahmottaminen oli haastavaa, ja yritän selittää sen tässä postauksessa niin selkeästi kuin mahdollista. Käsittelemme seuraavia käsitteitä:
- GDT, LDT, IDT.
- Virtuaalisen muistin kääntäminen.
- ASLR ja Kernel ASLR (KASLR).
Aloitetaan perusasioista, jokaisessa tietokoneessa on ainakin (toivottavasti) seuraavat komponentit: CPU, levy ja RAM-muisti. Jokaisella näistä komponenteista on keskeinen rooli järjestelmän kulussa. CPU suorittaa komentoja ja operaatioita muistissa (RAM), RAM-muisti sisältää käyttämämme tiedot ja mahdollistaa nopean ja luotettavan pääsyn niihin, levy sisältää pysyviä tietoja, joita tarvitsemme myös uudelleenkäynnistyksen tai sammuttamisen jälkeen. Aloitan tästä, koska vaikka tämä on hyvin perustavaa laatua, on tärkeää pitää tämä mielessä, ja kun luet tätä artikkelia, kysy itseltäsi, mistä komponentista puhumme juuri sillä hetkellä.
Käyttöjärjestelmä on ohjelmisto, joka organisoi kaiken tämän, ja myös se, joka mahdollistaa nopean, kätevän, johdonmukaisen ja tehokkaan käyttöliittymän, jonka avulla voidaan käyttää kaikkia sen ominaisuuksia – joista osa on pääsy laitteistoon, ja osa on käyttömukavuuden ja suorituskyvyn parantamista.
Kuten mikä tahansa hyvä ohjelmisto, käyttöjärjestelmä toimii kerroksittain, ja ydin on ensimmäinen kerros ja – mielestäni – tärkein niistä. Ymmärtääksemme ytimen merkityksen meidän on ensin ymmärrettävä sen toimintaa ja sen kohtaamia haasteita, joten tarkastellaanpa joitakin sen vastuualueita:
- Käsittele järjestelmäkutsuja (juuri sitä samaa käyttöliittymää, josta puhuimme).
- Kiintiöi resursseja (RAM-muistia, CPU:ta ja paljon muuta) käsillä oleville prosesseille/säikeille.
- Turvaa suoritetut toiminnot.
- Välittää laitteiston ja ohjelmiston välillä.
Monet näistä toimista suoritetaan prosessorin anteliaalla avustuksella, x86:n tapauksessa Protected mode on tila, jonka avulla voimme rajoittaa parhaillaan käynnissä olevan suorituskontekstin tehoa (käskykokonaisuutta).
Asettakaamme, että meillä on kaksi maailmaa – käyttäjän maailma ja valvojan maailma. Milloin tahansa voit olla vain toisessa näistä maailmoista. Kun olet käyttäjän maailmassa, näet maailman sellaisena kuin valvoja haluaa sinun näkevän sen. Katsotaanpa, mitä tarkoitan tällä:
Asetaan, että olet prosessi. Prosessi on yhden tai useamman säikeen säiliö. Säie on suorituskonteksti, se on looginen yksikkö, josta koneen ohjeet suoritetaan. Se tarkoittaa, että kun säie suorittaa, vaikkapa lukee muistiosoitteesta 0x80808080, se viittaa nykyisen prosessin virtuaaliosoitteeseen 0x80808080. Kuten voitte arvata, osoitteen sisältö on erilainen kahden prosessin välillä. Virtuaalinen osoiteavaruus on prosessitasolla, mikä tarkoittaa, että kaikilla saman prosessin säikeillä on sama osoiteavaruus ja ne voivat käyttää samaa virtuaalimuistia. Käyttäkäämme esimerkkinä säikeen tasolla olevasta resurssista kuuluisaa pinoa.
Minulla on siis säie, joka suorittaa seuraavan koodin:
Kuten näet, kaikilla säikeillä oli sama pinosegmentti, koska niillä on sama virtuaalinen osoiteavaruus. jokaisen pino-osoitin on erilainen, koska jokaisella on oma pino, johon se tallentaa arvonsa.
Sivuhuomautus pinosegmentistä – selitän segmenttirekistereistä enemmän GDT/LDT-osiossa – nyt ota sanani todesta.
Miksi tämä on tärkeää? Prosessori voi milloin tahansa jäädyttää säikeen ja antaa hallinnan mille tahansa muulle säikeelle, jolle se haluaa. Ytimen osana aikatauluttaja on se, joka jakaa suorittimen kulloinkin olemassa oleville (ja ”valmiille”) säikeille. Jotta säikeet voivat toimia luotettavasti ja tehokkaasti, on tärkeää, että jokaisella säikeellä on oma pino, johon se voi tallentaa asiaankuuluvat arvot (esimerkiksi paikalliset muuttujat ja paluuosoitteet).
Käyttösäikeiden hallitsemiseksi käyttöjärjestelmä ylläpitää kutakin säiettä varten erityistä rakennetta, jota kutsutaan TCB:ksi (säikeen ohjauslohkoksi), ja siihen tallennetaan muun muassa kyseisen säikeen konteksti ja tila (käynnissä/valmis / jne…). Konteksti sisältää – jälleen kerran – muun muassa CPU-rekisterien arvot:
- EBP -> Pinon perusosoite, kukin funktio käyttää tätä osoitetta perusosoitteena, josta se siirtyy käyttämään paikallisia muuttujia ja parametreja.
- ESP -> Tämänhetkinen osoitin pinon viimeisimpään arvoon (ensimmäinen ponnahdus).
- Yleiskäyttöiset rekisterit -> EAX, EBX jne…
- Flags-rekisteri.
- C3 -> sisältää sivuhakemiston sijainnin (käsitellään myöhemmin).
- EIP – Seuraavaksi suoritettava käsky.
Säikeiden (threads) lisäksi käyttöjärjestelmän on pidettävä kirjaa monien muidenkin asioiden perässä, mukaan lukien prosessit. Prosesseja varten käyttöjärjestelmä tallentaa PCB (Process Control Block) -rakenteen, sanoimme, että jokaiselle prosessille on oma eristetty osoiteavaruus. Oletetaan, että on olemassa taulukko, joka kuvaa jokaisen virtuaalisen osoitteen fyysiseen osoitteeseen, ja että tämä taulukko on tallennettu PCB:hen, ja käyttöjärjestelmän tehtävänä on päivittää tämä taulukko ja pitää se ajan tasalla fyysisen muistin oikean tilan mukaan. Joka kerta, kun aikatauluttaja vaihtaa suorituksen tietylle säikeelle, kyseisen säikeen omistavalle prosessille tallennettua taulukkoa sovelletaan prosessoriin, jotta se pystyy kääntämään virtuaaliosoitteet oikein.
Tässä riittää käsitteet, ymmärretään, miten se oikeasti tehdään. Sitä varten tarkastellaan maailmaa prosessorin näkökulmasta:
Global Descriptor Table
Me kaikki tiedämme, että prosessorilla on rekistereitä, jotka auttavat häntä tekemään laskutoimituksia, jotkut rekisterit enemmän kuin toiset (;)). Suunnitelmallisesti x86 tukee useita tiloja, mutta tärkeimmät ovat käyttäjä- ja valvottu tila, CPU:lla on erityinen rekisteri nimeltä gdtr (Global Descriptor Table Register), joka sisältää osoitteen erittäin tärkeään taulukkoon. tämä taulukko kuvaa jokaisen virtuaalisen osoitteen vastaavaan prosessorin tilaan, se sisältää myös kyseisen osoitteen oikeudet (READ | WRITE | EXECUTE). ilmeisesti kyseistä rekisteriä voidaan muuttaa vain valvotusta tilasta. Osana prosessorin suoritusta se tarkistaa, mikä käsky suoritetaan seuraavaksi (ja missä osoitteessa se on), se tarkistaa osoitteen GDT:stä ja näin se tietää, onko se kelvollinen käsky sen halutun moodin (CPU:n nykyinen moodi vastaa GDT:n moodia) ja oikeuksien (jos se ei ole suoritettavissa – virheellinen) perusteella. Esimerkkinä voidaan mainita käsky ”lgdtr”, joka lataa arvon gdtr-rekisteriin, ja se voidaan suorittaa vain valvotussa tilassa, kuten on mainittu. Tässä yhteydessä on korostettava, että kaikki muistitoimintojen suojaus (käskyn suorittaminen / kirjoittaminen epäkelpoiseen paikkaan / lukeminen epäkelpoisesta paikasta) tehdään GDT:ssä ja LDT:ssä (jotka tulevat seuraavaksi) prosessoritasolla käyttäen näitä käyttöjärjestelmän rakentamia rakenteita.
Tältä näyttää GDT:n / LDT:n merkinnän sisältö:
http://wiki.osdev.org/Global_Descriptor_Table
Kuten huomaatte, siinä on osoitealue, johon merkintä liittyy, ja sen attribuutit (käyttöoikeudet) odotetusti.
Local Descriptor Table
Kaikki, mitä sanoimme GDT:stä, pätee myös LDT:hen pienellä (mutta suurella) erolla. Nimensä mukaisesti GDT:tä sovelletaan globaalisti järjestelmässä, kun taas LDT:tä paikallisesti, mitä tarkoitan globaalisti ja paikallisesti? GDT pitää kirjaa kaikkien prosessien ja säikeiden käyttöoikeuksista, eikä se muutu kontekstinvaihdon yhteydessä, kun taas LDT:ssä näin on. On vain järkevää, että jos jokaisella prosessilla on oma osoiteavaruutensa, on mahdollista, että yhdelle prosessille osoite 0x10000000 on suoritettavissa ja toiselle se on vain luku/kirjoitusoikeus. Tämä pätee erityisesti ASLR:n ollessa päällä (käsitellään myöhemmin). LDT:n tehtävänä on pitää yllä oikeudet, jotka erottavat jokaisen prosessin toisistaan.
Yksi huomioitava asia on se, että kaikki sanottu on rakenteen tarkoitus, mutta todellisuudessa jotkut käyttöjärjestelmät saattavat käyttää tai olla käyttämättä osaa rakenteesta ollenkaan. esimerkiksi on mahdollista käyttää vain GDT:tä ja vaihtaa sitä kontekstinvaihdon välillä eikä koskaan käyttää LDT:tä. Se kaikki on osa käyttöjärjestelmän suunnittelua ja kompromisseja. Tuon taulukon merkinnät näyttävät samanlaisilta kuin GDT:n.
Selectors
Miten prosessori tietää mistä etsiä GDT:stä tai LDT:stä, kun se suorittaa tietyn käskyn? Prosessorilla on erityisiä rekistereitä, joita kutsutaan segmenttirekistereiksi:
https://en.wikibooks.org/wiki/X86_Assembly/X86_Architecture
- Stack Segment (SS). Pointer to the stack. - Code Segment (CS). Pointer to the code. - Data Segment (DS). Pointer to the data. - Extra Segment (ES). Pointer to extra data ('E' stands for 'Extra'). - F Segment (FS). Pointer to more extra data ('F' comes after 'E'). - G Segment (GS). Pointer to still more extra data ('G' comes after 'F').
Jokainen rekisteri on 16 bittiä pitkä ja sen rakenne on seuraava:
http://www.c-jump.com/CIS77/ASM/Memory/M77_0290_segment_registers_protected.htm
Tässä on siis GDT/LDT:n indeksi, siinä on myös bitti, joka kertoo, onko kyseessä LDT vai GDT, ja missä tilassa sen pitää olla (RPL 0 on supervisor, 4 on user).
Interrupt Descriptor Table
GDT:n ja LDT:n lisäksi meillä on myös IDT (Interrupt Descriptor Table), IDT on pelkkä taulukko, jossa on erittäin tärkeiden toimintojen osoitteet, osa niistä kuuluu käyttöjärjestelmälle, osa ajureille ja tietokoneeseen liitetyille fyysisille laitteille. Kuten gdtr, meillä on idtr, joka, kuten luultavasti arvasit, on rekisteri, jossa on IDT:n osoite. Mikä tekee IDT:stä niin erityisen? Kun käynnistämme keskeytyksen, CPU siirtyy automaattisesti valvottuun tilaan, mikä tarkoittaa, että kaikki IDT:n sisällä olevat toiminnot toimivat valvotussa tilassa. Jokainen säie jokaisessa tilassa voi käynnistää keskeytyksen antamalla ”int”-käskyn, jota seuraa numero, joka kertoo suorittimelle, missä indeksissä kohdefunktio sijaitsee. Näin ollen on nyt selvää, että jokainen IDT:n sisällä oleva funktio on potentiaalinen portti valvottuun tilaan.
Tiedämme siis, että meillä on GDT/LDT, joka kertoo suorittimelle kunkin virtuaaliosoitteen käyttöoikeudet, ja meillä on IDT, joka osoittaa ”portti”-funktiot rakkaaseen ytimeemme (joka ilmeisesti sijaitsee valvotun muistin osan sisällä). Miten nämä rakenteet käyttäytyvät käynnissä olevassa järjestelmässä?
Virtuaalimuisti
Ennen kuin voimme ymmärtää, miten tämä kaikki pelaa yhteen, meidän on käsiteltävä vielä yksi käsite – virtuaalimuisti. Muistatko, kun sanoin, että on olemassa taulukko, joka kartoittaa jokaisen virtuaalimuistin osoitteen sen fyysiseen osoitteeseen? Se on itse asiassa hieman monimutkaisempaa. Ensinnäkin emme voi yksinkertaisesti kartoittaa jokaista virtuaaliosoitetta, koska se vie enemmän tilaa kuin meillä todellisuudessa on, ja jos jätämme tehokkuuden tarpeen sivuun, käyttöjärjestelmä voi myös vaihtaa muistisivuja levylle (tehokkuuden ja suorituskyvyn vuoksi), on mahdollista, että tarvittavan virtuaaliosoitteen muistisivua ei ole muistissa tällä hetkellä, joten virtuaaliosoitteen fyysiseksi kääntämisen lisäksi meidän on myös tallennettava, onko muisti RAM-muistissa, ja jos se ei ole, niin missä se on (sivutiedostoja voi olla useampi kuin yksi). MMU (Memory Management Unit, muistinhallintayksikkö) on komponentti, joka vastaa virtuaalimuistin kääntämisestä fyysiseksi.
Yksi asia, joka on todella tärkeää ymmärtää, on se, että jokainen käsky jokaisessa tilassa käy läpi virtuaaliosoitteen kääntämisprosessin, jopa valvotussa tilassa oleva koodi. Kun CPU on suojatussa tilassa, jokainen käsky, jonka se suorittaa, käyttää virtuaaliosoitetta – ei koskaan fyysistä (on olemassa joitakin temppuja, joilla todellinen virtuaaliosoite käännetään aina täsmälleen samaan virtuaaliseen muistiin, mutta se ei kuulu tämän viestin aihepiiriin).
Siinä vaiheessa, kun se on suojatussa tilassa, mistä CPU tietää, mistä etsiä, kun sen on käännettävä virtuaaliosoite? Vastaus on CR3-rekisteri, joka pitää hallussaan osoitteen rakenteeseen, joka sisältää tarvittavaa informaatiota – Sivuhakemisto. Sen arvo muuttuu kulloinkin käynnissä olevan prosessin mukaan (jälleen eri virtuaaliosoiteavaruus).
Miten tämä sivuhakemisto sitten näyttää? Tehokkuuden kannalta meidän on voitava kysyä tätä ”taulukkoa” mahdollisimman nopeasti, ja sen on myös oltava mahdollisimman pieni, koska tämä taulukko luodaan jokaista prosessia varten. Ratkaisu tähän ongelmaan on suorastaan nerokas. Paras kuva, jonka löysin havainnollistamaan käännösprosessia, on tämä (wikipediasta):
MMU:lla on kaksi syötettä, virtuaalinen osoite, joka käännetään, ja CR3 (kyseisen sivuhakemiston osoite). x86-spesifikaatio pilkkoo virtuaaliosoitteen 3 osaan:
- 10-bittinen numero – indeksi sivuhakemistoon.
- 10-bittinen numero – indeksi sivutaulukkoon.
- 12-bittinen numero – offset itse fyysiseen osoitteeseen.
Prosessori ottaa siis ensimmäisen 10-bittisen numeron ja käyttää sitä indeksinä sivuhakemistoon, jokaista sivuhakemiston merkintää varten meillä on sivutaulukko, jonka jälkeen prosessori käyttää seuraavaa 10-bittistä numeroa indeksinä. Kukin hakemistotaulukon merkintä osoittaa 4K:n rajamuistisivun, jonka viimeistä 12-bittistä siirtymää virtuaaliosoitteesta käytetään osoittamaan tarkka sijainti fyysisessä muistissa. Tuon ratkaisun nerokkuus on:
- Joustavuus siinä, että kukin virtuaaliosoite paikallistaa täysin toisistaan riippumattoman fyysisen osoitteen.
- Rakenteiden tilatehokkuus on hämmästyttävää.
- Kaikkien taulukoiden jokaista merkintää ei käytetä, vaan taulukoissa on vain ne virtuaaliosoitteet, joita prosessi todella käyttää ja jotka se on kartoittanut.
Olen todella pahoillani, etten selitä tätä prosessia yksityiskohtaisemmin, tämä on hyvin dokumentoitu prosessi, jonka selittämiseksi monet ihmiset tekivät kovasti töitä paremmin kuin minä ikinä pystyisin – googleta se.
Kernel vs. käyttäjä
Tässä kohtaa homma muuttuu mielenkiintoiseksi (ja maagiseksi, jos saan sanoa).
Aloitimme tämän artikkelin toteamalla, että käyttöjärjestelmä orkestroi kaiken, ja se tekee sen kernelin avulla. Kuten jo todettiin, kernel pyörii muistiosiossa, joka on kartoitettu GDT:ssä vain valvotuksi tilaksi kaikille prosesseille. Kyllä, tiedän, että jokaisella prosessilla on oma osoiteavaruus, mutta ydin leikkaa osoiteavaruuden (yleensä ylempi puolisko, riippuu käyttöjärjestelmästä) omaan käyttöönsä, ei vain leikkaa osoiteavaruutta vaan myös samassa osoitteessa kaikille prosesseille. Tämä on tärkeää, koska ytimen koodi on kiinteää ja jokaisen viittauksen muuttujiin ja rakenteisiin on oltava samassa paikassa kaikille prosesseille. Voit tarkastella ydintä erityisenä kirjastona, joka ladataan jokaiseen prosessiin samaan paikkaan.
Kaikkea syvemmälle keskeytyksiin
Tiedämme, että IDT sisältää funktioiden osoitteita, näitä funktioita kutsutaan ISR:ksi (Interrupt Service Routine), jotkin niistä suoritetaan, kun laitteistotapahtuma tapahtuu (näppäimen painallus näppäimistöllä) ja toiset, kun ohjelmisto käynnistää keskeytyksen esimerkiksi vaihtaakseen kernel-moodiin.
Windowsissa on viileä konsepti keskeytyksille ja niiden priorisoinnille: Yksi erityisen tärkeä keskeytys on kellon tikitys. Jokaisella kellon tikillä on keskeytys, jonka sen ISR käsittelee. Käyttöjärjestelmän ajastin käyttää tätä kellotapahtumaa kontrolloidakseen, kuinka kauan kukin prosessi on käynnissä ja onko toisen prosessin vuoro vai ei. Kuten ymmärrätte, tämä keskeytys on erittäin tärkeä, ja se on käsiteltävä heti, kun se tapahtuu, mutta kaikki ISR:t eivät ole yhtä tärkeitä, ja tässä kohtaa keskeytysten väliset prioriteetit astuvat kuvaan. Otetaan esimerkiksi näppäimistön näppäimen painallus ja oletetaan, että sen prioriteetti on 1. Painoin juuri näppäintä näppäimistöllä ja sen ISR suoritetaan, kun näppäimistön ISR:ää suoritetaan, kaikki keskeytykset, joilla on sama prioriteetti tai alempi, jätetään huomiotta. ISR:n suorituksen aikana kellon ISR käynnistyy prioriteetilla 2 (minkä vuoksi se ei ole poistettu käytöstä), välitön vaihto tapahtuu kellon ISR:ään, kun kello on valmis, se palaa takaisin näppäimistön ISR:ään, josta se pysähtyi. näitä keskeytysten prioriteetteja kutsutaan IRQL:ksi (Interrupt ReQuest Level, keskeytyksen hakutaso), keskeytyksen IRQL:n noustessa sen prioriteetti on korkeampi. Keskeytykset, joilla on korkein prioriteetti, eivät koskaan ole keskeytyksiä keskellä, ne toimivat loppuun asti, aina. IRQL on windows-kohtainen – IRQL on numero väliltä 0-31, Linuxissa sitä ei ole, Linux käsittelee kaikki keskeytykset samalla prioriteetilla ja yksinkertaisesti poistaa kaikki keskeytykset käytöstä, kun se todella tarvitsee tietyn rutiinin, jotta sitä ei häiritä. Kuten näet, kyse on suunnittelusta ja mieltymyksistä.
Kytketään tämä kaikki rakkaaseen käyttäjätilaamme . Tämän kellotapahtuman ISR suorittaa riippumatta siitä, mikä säie on parhaillaan käynnissä, ja saattaa jopa keskeyttää toisen ISR:n tehtävään liittymättömään tehtävään. tämä on täydellinen esimerkki siitä, miksi ydin on samassa osoitteessa kaikille prosesseille emme halua vaihtaa GDT:tä ja sivuhakemistoa (C3:ssa) joka kerta, kun suoritamme keskeytyksen, koska se tapahtuu PALJON kertoja jopa yksittäisen toiminnon aikana jossakin tietyssä user mode -prosessissa. Paljon tapahtuu niiden koodirivien välissä, joita kirjoitat kehittäessäsi käyttäjätilan sovellusta (;)).
Toinen tapa tarkastella keskeytyksiä on ulkoisina ja itsenäisinä syötteinä käyttöjärjestelmällemme, tämä määritelmä ei ole tarkka (kaikki keskeytykset eivät ole ulkoisia tai itsenäisiä), mutta se on hyvä tehdä asia selväksi, iso osa ytimen työstä on saada tolkkua tapahtumiin, jotka tapahtuvat koko ajan jokaisesta paikasta (syöttölaitteista), ja toiselta puolelta palvella näitä tapahtumia ja toiselta puolelta varmistaa, että kaikki on oikeassa suhteessa toisiinsa.
Kaiken tämän ymmärtämiseksi aloitetaan yksinkertaisella käyttäjätilan sovelluksella, joka suorittaa seuraavan käskyn:
0x0000051d push ebp;
Kunkin käskyn kohdalla CPU tutkii ensin käskyn osoitteen (tässä tapauksessa ’0x0000051d’) GDT/LDT:tä käyttäen koodisegmenttirekisteriä (’cs’, koska kyseessä on käsky, joka on suoritettava) tietääkseen, mitä indeksiä taulukosta on etsittävä (muistakaa, että segmenttirekisterin avulla CPU tietää tarkalleen, mistä kohtaa sitä on etsittävä). Kun CPU tietää, että käsky on suoritettavassa paikassa ja että olemme oikeassa kehässä (käyttäjätilassa/ytimen tilassa), se jatkaa käskyn suorittamista. Tässä tapauksessa ’push ebp’ -käsky ei vaikuta vain rekisteriin vaan myös ohjelman pinoon (se työntää pinoon ebp:n sisällön), joten CPU tarkistaa myös GDT/LDT:stä esp-rekisterin sisällä olevan osoitteen (pinon nykyisen sijainnin osoite, ja koska kyseessä on pinon sijainti, CPU tietää käyttää siihen pinon segmenttirekisteriä) varmistaakseen, että se on kirjoitettavissa kyseisessä rengastuksessa. Huomaa, että jos tämä käsky myös lukisi muistista, CPU:n olisi pitänyt tarkistaa kyseinen osoite myös lukukäyttöä varten.
Ei tässä vielä kaikki, sen jälkeen kun CPU on tarkistanut kaikki turvallisuusnäkökohdat, sen on nyt päästävä muistiin käsiksi ja manipuloitava sitä, kuten muistat, osoitteet ovat virtuaalisessa muodossaan. MMU kääntää nyt jokaisen käskyn määrittelemän virtuaalimuistin fyysiseksi muistiosoitteeksi käyttämällä CR3-rekisteriä, joka osoittaa sivuhakemistoon (joka osoittaa sivutaulukkoon), jonka avulla osoitteen voi lopulta kääntää fyysiseksi. Huomaa, että osoite ei välttämättä ole muistissa sillä hetkellä, kun sitä tarvitaan, jolloin käyttöjärjestelmä luo sivuvian (poikkeus, joka aiheuttaa keskeytyksen) ja tuo tiedot fyysiseen muistiin puolestamme ja jatkaa sitten suoritusta (tämä on läpinäkyvää käyttäjätilan sovellukselle).
Käyttäjältä ytimelle
Jokainen vaihto käyttäjätilan ja ytimen välillä tapahtuu IDT:n avulla. Käyttäjätilan sovelluksesta käsky ’int <num>’ siirtää suorituksen IDT:ssä olevalle funktiolle indeksillä num. Kun suoritus on kernel-moodissa, monet säännöt muuttuvat, kullakin säikeellä on erilaiset pinot käyttäjä- ja kernel-moodissa, muistin käytön tarkistukset ovat paljon monimutkaisempia ja pakollisia, kernel-moodissa on hyvin vähän, mitä ei voi tehdä, ja paljon, mitä voi rikkoa.
ASLR ja KASLR
Useimmiten ”vain” tiedonpuute estää mahdottomia saavutuksia.
ASLR (Address Space Layout Randomization) on käsite, joka on toteutettu eri tavalla jokaisessa käyttöjärjestelmässä, käsite on satunnaistaa prosessien ja niiden lataamien kirjastojen virtuaaliosoitteet.
Ennen kuin sukellamme sisään halusin huomata, että päätin sisällyttää ASLR:n tähän postaukseen, koska se on mukava tapa nähdä, miten suojattu tila ja sen rakenteet mahdollistivat tämänkaltaisen kyvykkyyden, vaikka se ei olekaan se, joka sen toteuttaa tai joka ei ole siitä vastuussa.
Miksi ASLR:n?
Miksi se on helppoa, hyökkäysten estämiseksi. Kun joku pystyy pistämään koodia käynnissä olevaan prosessiin, joidenkin hyödyllisten funktioiden osoitteiden tuntemattomuus voi aiheuttaa hyökkäyksen epäonnistumisen.
Meillä on jo nyt eri osoiteavaruus jokaiselle prosessille, tämä tarkoittaa sitä, että ilman ASLR:ää kaikilla prosesseilla olisi samat perusosoitteet, tämä johtuu siitä, että kun jokaisella prosessilla on oma virtuaalinen osoiteavaruus, meidän ei tarvitse varoa prosessien välisiä törmäyksiä. Kun linkitämme ohjelman, linkittäjä valitsee kiinteän perusosoitteen, johon se linkittää suoritettavan ohjelman. Paperilla kaikilla suoritettavilla tiedostoilla, jotka sama linkittäjä on linkittänyt oletusparametreilla (perusosoite voidaan tarvittaessa konfiguroida), on sama perusosoite. Esimerkkinä kirjoitin kaksi sovellusta, joista toisen nimi on ”1.exe” ja toisen ”2.exe”, molemmat ovat eri projekteja Visual Studiossa ja silti molemmilla on sama perusosoite (käytin exeinfo PE:tä PE-tiedoston perusosoitteen tarkistamiseen):
Ei vain näillä kahdella suoritettavalla tiedostolla ole samaa perusosoitetta, vaan ne molemmat eivät tue ASLR:ää (poistin sen käytöstä):
Näet sen myös PE-muodon sisältämänä kohdassa File Characteristics:
Jatketaan nyt molempien suoritettavien tiedostojen suorittamista samaan aikaan, ja molemmilla on sama perusosoite (käytän Sysinternalsin vmmap-ohjelmaa peruskuvauksen tarkasteluun):
Näemme, että kumpikaan prosessi ei käytä ASLR:ää, ja että niillä on sama perusosoite 0x00400000. Jos olisimme hyökkääjiä ja meillä olisi pääsy tähän suoritettavaan tiedostoon, olisimme voineet tietää tarkalleen, mitkä osoitteet ovat tämän prosessin käytettävissä, kun löytäisimme pois pistääksemme itsemme sen suoritukseen. otetaan ASLR käyttöön suoritettavassa tiedostossamme 1.exe ja nähdään sen taika:
Muutos!
KASLR (Kernel ASLR) on sama kuin ASLR, mutta se toimii ytimen tasolla, mikä tarkoittaa, että kun hyökkääjä on kyennyt tunkeutumaan ytimen kontekstiin, hän (toivottavasti) ei pysty tietämään, mitkä osoitteet sisältävät mitäkin rakenteita (esimerkiksi missä GDT sijaitsee muistissa). Mainittakoon tässä yhteydessä, että ASLR tekee taikojaan jokaisen uuden prosessin spawnin yhteydessä (jotka tietysti tukevat ASLR:ää), kun taas KASLR tekee sen jokaisen uudelleenkäynnistyksen yhteydessä, koska silloin kernel ”spawnataan”.
Miten ASLR:ää käytetään?
Miten se siis toimii ja miten se liittyy suojattuun tilaan? Se joka vastaa ASLR:n toteuttamisesta on lataaja. Kun prosessi käynnistetään, lataajan täytyy laittaa se muistiin, luoda tarvittavat rakenteet ja käynnistää sen säie. Lataaja tarkistaa ensin, tukeeko suoritettava ohjelma ASLR:ää, ja jos tukee, se satunnaistaa jonkin perusosoitteen käytettävissä olevien osoitteiden joukosta (esimerkiksi ytimen tila ei tietenkään ole käytettävissä). Tämän osoitteen perusteella lataaja alustaa nyt kyseisen prosessin sivuhakemiston osoittamaan satunnaistetun osoiteavaruuden fyysiseen osoiteavaruuteen. LDT:n joustavuus tulee myös avuksi, sillä lataaja luo yksinkertaisesti LDT:n, joka vastaa satunnaistettua osoitetta ja jolla on asianmukaiset käyttöoikeudet. Kauneus tässä on se, että suojattu tila ei edes tiedä, että ASLR:ää käytetään, se on tarpeeksi joustava, jotta se ei välitä siitä.
Mielenkiintoinen yksityiskohta toteutuksessa on se, että windowsissa satunnaistettu osoite tietylle suoritettavalle ohjelmalle on kiinteä tehokkuuden vuoksi. Tarkoitan tällä sitä, että jos satunnaistamme osoitteen vaikkapa calc.exe:lle, toisella suorituskerralla perusosoite on sama. Jos siis avaan kaksi laskinta samaan aikaan, niillä on sama perusosoite. Kun suljen molemmat laskimet ja avaan ne uudelleen, molemmilla on jälleen sama osoite, mutta tämä on eri kuin edellisten laskinten osoite. Miksi tämä ei ole tehokasta, kysyt? ajattele yleisesti käytettyjä DLL-ohjelmia. Monet prosessit käyttävät niitä, ja jos niiden perusosoitteet olisivat erilaiset jokaisessa prosessissa, myös niiden koodi olisi erilainen (koodi viittaa tietoihin käyttäen tätä perusosoitetta), ja jos koodi on erilainen, DLL on ladattava muistiin jokaista prosessia varten. Todellisuudessa käyttöjärjestelmä lataa kuvat vain kerran kaikille kyseistä kuvaa käyttäville prosesseille. Se säästää tilaa – paljon tilaa!
Johtopäätös
Tässä vaiheessa sinun pitäisi pystyä hahmottamaan ydin työssä ja ymmärtämään, miten kaikki x86-arkkitehtuurin keskeiset rakenteet pelaavat yhteen isommassa kuvassa ja mahdollistavat sen, että voimme ajaa mahdollisesti vaarallisia sovelluksia käyttäjätilassa ilman (tai vain vähän) pelkoa.