Hoe geheugenaanwijzers bufferoverlopen veroorzaken in C

11

Om echt te begrijpen waarom pointers zich gedragen zoals ze doen, moet je stoppen met denken in termen van abstracte variabelen en beginnen met het visualiseren van de hardware. Het is een eenvoudig model, maar het vormt de basis van alles, van segmentfouten tot beveiligingsproblemen. Als je de basisprincipes van bits, bytes en woorden niet goed kent, moet je hier waarschijnlijk even pauzeren en dat eerst opfrissen. De rest van deze uitleg gaat ervan uit dat je het verschil kent tussen een byte en een bit.

Uw computer beschikt over geheugen of RAM (Random Access Memory). Mogelijk hebt u er 16, 32 of 64 gigabyte van geïnstalleerd. Dit is niet alleen opslag; het is de werkruimte. Het bevat de code die uw CPU momenteel uitvoert en de gegevens die deze programma’s op dit moment manipuleren. Beschouw RAM als een enorme, lineaire reeks bytes.

Elke byte in die array heeft een adres. De eerste byte is adres 0. De volgende is 1. Dan 2, 3, enzovoort. Deze adressen functioneren precies zoals indexen in een standaard programmeerarray. Omdat de CPU onmiddellijk naar elk adres kan springen, wordt dit ‘random access memory’ genoemd. Het hoeft niet opeenvolgend te worden gelezen. Het kan grijpen wat het nodig heeft, wanneer het het nodig heeft. Wanneer de compiler een groter gegevenstype moet opslaan, pakt hij gewoon een blok aaneengesloten bytes. Een standaard getal met drijvende komma neemt bijvoorbeeld gewoonlijk vier aaneengesloten bytes in beslag.

Laten we eens kijken naar een globale verklaring in C:

Voor jou is dit een variabele genaamd f die een float bevat. Voor de computer is dit een opdracht om 4 specifieke bytes in de geheugenarray te reserveren. Laten we zeggen dat de compiler deze bytes toewijst aan geheugenlocatie 248.440.

Wanneer je schrijft:

De compiler denkt niet aan “updatevariabele f.” Het denkt: “laad de waarde 3.14 in geheugenadres 248.440.” De abstractie is dun. Onder de motorkap draait het allemaal om adressen en de waarden die op die adressen zitten.

Deze mechanische aard van het geheugen leidt tot enkele vervelende bijwerkingen. Beschouw dit C-codefragment:

Je zou verwachten dat s en t 0:0, 1:1, 2:2, 3:3 bevatten. En u moet 0 blijven. In plaats daarvan ziet de uitvoer er als volgt uit:

Wacht. Wat is er met t[0] gebeurd? En waarom is u nu 5?

Het probleem is een klassieke bufferoverflow. Kijk goed naar de lusvoorwaarde: i<=4. De array t wordt gedeclareerd als t[4], wat betekent dat het de indices 0, 1, 2 en 3 heeft. Wanneer i 4 bereikt, schrijft de code naar t[4]. Die index bestaat niet binnen de toegewezen ruimte voor t. Het schrijft één element voorbij het einde van de array.

Omdat het geheugen aaneengesloten is, plaatst de computer s, t en u direct naast elkaar in de heap of stapel.

Wanneer u voorbij het einde van een array schrijft, crasht u niet onmiddellijk. Je overschrijft alles wat zich in het geheugenslot ernaast bevindt.

In dit specifieke geval overschrijft het schrijven naar t[4] de geheugenlocatie met u (die oorspronkelijk 0 was). Maar waarom is `u

Schrijf naar s[4]. Die index bestaat niet. Maar de computer maakt het niets uit. Er wordt niet gecontroleerd of u op uw eigen tenen trapt. Het berekent alleen het adres. En dat adres komt toevallig op t[0] terecht.

Je denkt dat je naar s schrijft. Je schrijft eigenlijk naar t. Het systeem voert de opdracht uit zonder te knipperen. De logica is gebrekkig. Het programma corrumpeert gegevens. Het is stil totdat het niet meer zo is.

Probeer nu iets ergers.

s[1000000] = 5;

U schrijft naar het geheugen waarvan uw programma niet de eigenaar is. De gevolgen zijn volledig afhankelijk van het besturingssysteem dat u gebruikt. Op beschermde systemen zoals UNIX, Windows NT of Windows 98 merkt het besturingssysteem deze overtreding onmiddellijk op. Het beëindigt het programma. Moeilijk stoppen. Het beschermt de rest van het systeem tegen uw fout.

Oudere systemen zoals Windows 3.1 of klassiek Mac OS hebben die luxe niet. Ze weten niet wat je doet. Uiteindelijk overschrijft u code of variabelen in een andere toepassing. Het resultaat? Een probleempje. Een ongeluk. Of erger nog, een systeembrede storing die willekeurig aanvoelt, maar in werkelijkheid het directe gevolg is van ongecontroleerde geheugentoegang.

In het geheugen zitten variabelen als i, s, t en u naast elkaar op specifieke adressen. Ze zijn buren. Als je voorbij de grens van het ene schrijft, kom je terecht in het volgende. De computer doet precies wat je zei. Het maakt gewoon niet uit dat je het goed bedoelde.

Het gevaar van ongecontroleerde array-toegang

C en C++ voeren geen bereikcontrole uit. Ze gaan ervan uit dat je weet wat je doet. Dit betekent dat u goed moet letten op arraybereiken. Als u buiten de grenzen leest of schrijft, is het gedrag gebrekkig. Onvoorspelbaar. Gevaarlijk.

Dit gebrek aan veiligheid is de reden waarom niet-geïnitialiseerde pointerfouten zo vaak voorkomen in C. Beschouw deze code eens:

De compiler drukt de waarde in p en het adres van i af. Aanvankelijk bevat p rommel of nul. Het adres van i is meestal een groot getal. U ziet bijvoorbeeld:

Na p = &i bevat p het adres van i. Eenvoudig.

Kijk nu eens hiernaar:

Hiermee wordt de waarde afgedrukt waarnaar p verwijst. Maar p is niet geïnitialiseerd. Het verwijst naar adres 0 of een willekeurig stukje geheugen. Het resultaat is bijna altijd een segmentatiefout. Een runtimefout. U probeert toegang te krijgen tot geheugen dat niet van u is.

Aanwijzingen in een nieuw licht

Zodra u deze risico's begrijpt, veranderen de aanwijzingen. Het zijn niet alleen variabelen. Het zijn directe adressen naar geheugenlocaties.

Neem dit programma:

Dit is wat er gebeurt:

Hoe pointers geheugenadressen opslaan

Wanneer u een integer-variabele zoals i declareert, neemt deze 4 bytes ruimte in RAM in beslag. Een pointer, laten we hem p noemen, neemt ook 4 bytes in beslag. Deze omvang geldt voor de overgrote meerderheid van de systemen die momenteel in omloop zijn, waarbij geheugenadressen 32 bits lang zijn. De industrie verschuift langzaam naar 64-bits adressering, maar voorlopig is de footprint van 4 bytes de standaard.

Beschouw i als een huis. Het heeft een specifiek adres, bijvoorbeeld 248.440. De aanwijzer p is niet het huis zelf. Het is een stuk papier met dat adres erop geschreven. Wanneer u p = &i toekent, schrijft u 248.440 op dat papier.

Dit onderscheid is van cruciaal belang voor het begrijpen van hoe pointers werken in C. De aanwijzer heeft niet de waarde i. Het bevat de locatie waar i woont. Als u p derefereert (met behulp van p *), gaat u naar dat adres en leest u de waarde. In die zin zijn p en i * functioneel identiek. Ze verwijzen naar dezelfde gegevens.

Adres in code bekijken

U kunt dit gedrag verifiëren met een eenvoudige printverklaring.

printf("%d", p);

Met deze opdracht wordt de waarde van i niet afgedrukt. Het drukt het nummer af dat op het papier staat. De uitvoer is het feitelijke geheugenadres van i.

Waarom doet dit er toe? Omdat u hiermee locaties kunt doorgeven in plaats van kopieën van gegevens. U kunt het geheugen rechtstreeks manipuleren. Je kunt structuren aan elkaar koppelen. U kunt heapgeheugen efficiënt beheren. De aanwijzer is een referentie. De variabele is de inhoud. Het verwarren van de twee leidt tot bugs. Het begrijpen van het verschil leidt tot controle.

Sommige ontwikkelaars beschouwen pointers als magie. Dat zijn ze niet. Het zijn maar cijfers. Geheugenadressen zijn gehele getallen. Behandel ze als zodanig.

Vorig artikelStop met het verspillen van geld aan bulk-URL-inzendingsservices voor SEO
Volgend artikelTwitch wil je meer weten? Canlı yayın dünyasına hızlı giriş