Ist es sicher, auf x86 und x64 über das Ende eines Puffers innerhalb derselben Seite hinaus zu lesen?

Lesezeit: 10 Minuten

Benutzeravatar von BeeOnRope
BeeOnRope

Viele Methoden, die in Hochleistungsalgorithmen zu finden sind, könnten (und werden) vereinfacht, wenn sie eine kleine Menge über das Ende von Eingabepuffern hinaus lesen dürften. „Kleine Menge“ bedeutet hier im Allgemeinen bis zu W - 1 Bytes nach dem Ende, wo W ist die Wortgröße in Bytes des Algorithmus (z. B. bis zu 7 Bytes für einen Algorithmus, der die Eingabe in 64-Bit-Blöcken verarbeitet).

Es ist klar, dass Schreiben über das Ende eines Eingabepuffers hinaus ist im Allgemeinen niemals sicher, da Sie möglicherweise Daten über den Puffer hinaus verstopfen1. Es ist auch klar, dass das Lesen über das Ende eines Puffers hinaus in eine andere Seite einen Segmentierungsfehler/eine Zugriffsverletzung auslösen kann, da die nächste Seite möglicherweise nicht lesbar ist.

Im Spezialfall des Lesens von ausgerichteten Werten scheint ein Seitenfehler jedoch zumindest auf x86 unmöglich. Auf dieser Plattform haben Seiten (und damit Speicherschutz-Flags) eine 4K-Granularität (größere Seiten, z. B. 2 MiB oder 1 GiB, sind möglich, aber dies sind Vielfache von 4 K), und daher greifen ausgerichtete Lesevorgänge nur auf Bytes auf derselben Seite wie die gültige zu Teil des Puffers.

Hier ist ein kanonisches Beispiel für eine Schleife, die ihre Eingabe ausrichtet und bis zu 7 Bytes nach dem Ende des Puffers liest:

int processBytes(uint8_t *input, size_t size) {

    uint64_t *input64 = (uint64_t *)input, end64 = (uint64_t *)(input + size);
    int res;

    if (size < 8) {
        // special case for short inputs that we aren't concerned with here
        return shortMethod();
    }

    // check the first 8 bytes
    if ((res = match(*input)) >= 0) {
        return input + res;
    }

    // align pointer to the next 8-byte boundary
    input64 = (ptrdiff_t)(input64 + 1) & ~0x7;

    for (; input64 < end64; input64++) {
        if ((res = match(*input64)) > 0) {
            return input + res < input + size ? input + res : -1;
        }
    }

    return -1;
}

Die innere Funktion int match(uint64_t bytes) wird nicht angezeigt, aber es ist etwas, das nach einem Byte sucht, das einem bestimmten Muster entspricht, und die niedrigste derartige Position (0-7) zurückgibt, wenn sie gefunden wird, oder -1 andernfalls.

Zunächst werden Fälle mit einer Größe < 8 zur Vereinfachung der Darstellung einer anderen Funktion verpfändet. Dann wird eine einzelne Prüfung für die ersten 8 (nicht ausgerichtete Bytes) durchgeführt. Dann wird eine Schleife für den Rest gemacht floor((size – 7) / 8) Blöcke von 8 Bytes2. Diese Schleife kann bis zu 7 Bytes nach dem Ende des Puffers lesen (der 7-Byte-Fall tritt auf, wenn input & 0xF == 1). Rückruf hat jedoch eine Prüfung, die jeden ausschließt falsche Übereinstimmungen die über das Ende des Puffers hinaus auftreten.

Ist eine solche Funktion praktisch auf x86 und x86-64 sicher?

Diese Arten von überliest sind in Hochleistungscode üblich. Spezieller Endcode, um solches zu vermeiden überliest ist auch üblich. Manchmal sieht man, dass der letztere Typ den ersteren ersetzt, um Werkzeuge wie Valgrind zum Schweigen zu bringen. Manchmal sieht man ein Vorschlag eine solche Ersetzung durchzuführen, die mit der Begründung abgelehnt wird, dass das Idiom sicher und das Werkzeug fehlerhaft (oder einfach zu konservativ) ist.3.

Hinweis für Sprachjuristen:

Das Lesen von einem Zeiger über seine zugewiesene Größe hinaus ist im Standard definitiv nicht erlaubt. Ich schätze Antworten von Sprachjuristen und schreibe sie gelegentlich sogar selbst, und ich freue mich sogar, wenn jemand das Kapitel und den Vers ausgräbt, die den obigen Code zeigen undefiniertes Verhalten und daher im strengsten Sinne nicht sicher (und ich werde die Details hier kopieren). Letztlich ist das aber nicht das, was ich anstrebe. Aus praktischer Sicht sind viele gebräuchliche Redewendungen, die Zeigerkonvertierung, Strukturzugriff durch solche Zeiger usw. umfassen, technisch undefiniert, aber in Code hoher Qualität und hoher Leistung weit verbreitet. Oft gibt es keine Alternative, oder die Alternative läuft mit halber Geschwindigkeit oder weniger.

Wenn Sie möchten, ziehen Sie eine modifizierte Version dieser Frage in Betracht, die lautet:

Nachdem der obige Code in die x86/x86-64-Assembly kompiliert wurde und der Benutzer überprüft hat, dass er auf die erwartete Weise kompiliert wurde (dh der Compiler hat keinen nachweisbaren teilweise außerhalb der Grenzen liegenden Zugriff verwendet, um wirklich etwas zu tun clever, ist die Ausführung des kompilierten Programms sicher?

Insofern ist diese Frage sowohl eine C-Frage als auch eine x86-Assembler-Frage. Der größte Teil des Codes, der diesen Trick verwendet, den ich gesehen habe, ist in C geschrieben, und C ist immer noch die dominierende Sprache für Hochleistungsbibliotheken, die leicht Dinge auf niedrigerer Ebene wie asm und Dinge auf höherer Ebene wie in den Schatten stellt. Zumindest außerhalb der numerischen Hardcore-Nische, in der FORTRAN immer noch Ball spielt. Daher interessiere ich mich für die C-Compiler und darunter Sicht auf die Frage, weshalb ich sie nicht als reine x86-Assembler-Frage formuliert habe.

Alles in allem bin ich zwar nur mäßig an einem Link zum Standard interessiert, der zeigt, dass dies UD ist, aber ich bin sehr an allen Details tatsächlicher Implementierungen interessiert, die dieses spezielle UD verwenden können, um unerwarteten Code zu erzeugen. Jetzt tue ich es nicht denken Dies kann ohne eine tiefgreifende, ziemlich tiefgreifende verfahrensübergreifende Analyse passieren, aber das gcc-Überlaufzeug hat auch viele Leute überrascht …


1 Auch in scheinbar harmlosen Fällen, zB wenn der gleiche Wert zurückgeschrieben wird, kann es passieren gleichzeitigen Code brechen.

2 Hinweis: Damit diese Überlappung funktioniert, muss diese Funktion und match() Funktion, sich auf eine bestimmte idempotente Weise zu verhalten – insbesondere, dass der Rückgabewert überlappende Prüfungen unterstützt. Also ein “Erstes Byte passendes Muster finden” funktioniert da schon alles match() Anrufe sind noch in Ordnung. Ein “Zähle Bytes übereinstimmendes Muster”-Verfahren würde jedoch nicht funktionieren, da einige Bytes doppelt gezählt werden könnten. Nebenbei bemerkt: Einige Funktionen wie der Aufruf “Return the Minimum Byte” würden auch ohne die In-Order-Beschränkung funktionieren, müssen aber alle Bytes untersuchen.

3 Es ist erwähnenswert, dass dies für valgrinds Memcheck es gibt eine Fahne, --partial-loads-ok die steuert, ob solche Lesevorgänge tatsächlich als Fehler gemeldet werden. Die Voreinstellung ist Jawohlbedeutet, dass im Allgemeinen solche Ladevorgänge nicht als unmittelbare Fehler behandelt werden, sondern dass versucht wird, die nachfolgende Verwendung geladener Bytes zu verfolgen, von denen einige gültig und andere nicht sind, wobei ein Fehler gekennzeichnet wird, wenn das Out- Of-Range-Bytes sind Gebraucht. In Fällen wie dem obigen Beispiel, in denen auf das gesamte Wort zugegriffen wird match(), wird eine solche Analyse zu dem Schluss kommen, dass auf die Bytes zugegriffen wird, obwohl die Ergebnisse letztendlich verworfen werden. Valgrind kann es generell nicht festzustellen, ob tatsächlich ungültige Bytes aus einem Teilladevorgang verwendet werden (und die Erkennung im Allgemeinen wahrscheinlich ist sehr schwer).

  • Theoretisch könnte ein C-Compiler eigene Prüfungen implementieren, die restriktiver sind als die der zugrunde liegenden Hardware.

    – Barmar

    13. Juni 2016 um 23:43 Uhr

  • Wenn Ihr Benutzer überprüft hat, dass es auf “die erwartete Weise” kompiliert wurde, wobei die erwartete Weise darin besteht, dass der Zugriff sicher ist, dann ist es sicher. Wenn Ihr Benutzer den Assembler-Zwischencode nicht liest, hat er leider keine solchen Garantien. Tu es nicht. (Sie können es sicher machen, indem Sie Ihr eigenes Speichermanagement implementieren.)

    – Bad Zen

    13. Juni 2016 um 23:44 Uhr


  • Das sieht eher nach einer Antwort als nach einer Frage aus 🙂 Was den speziellen Endcode angeht, wird das normalerweise nur gemacht, wenn der Algorithmus in Blöcken fortfährt, aber nicht zuerst ausgerichtet wird.

    – Narr

    13. Juni 2016 um 23:44 Uhr

  • Nun, es gibt immer asm(). 🙂

    – Barmar

    14. Juni 2016 um 0:00 Uhr

  • In Bezug auf Ihre erste Frage gibt C keine Garantie dafür, dass das Speichermodell, mit dem Sie arbeiten, überhaupt irgendetwas in der zugrunde liegenden Hardware für diese Art von „Edge Case“ entspricht (mit ein paar Ausnahmen für Dinge wie die Wortgröße und sogar dann es kämpft). Also No-Go an dieser Front. Die “Sprache Legalese” sagt aus gutem Grund “undefiniert”. In Bezug auf die zweite Frage müssten Sie einen bestimmten ASM posten, damit die Frage sinnvoll ist.

    – Bad Zen

    14. Juni 2016 um 0:11 Uhr


  • @DavidC.Rankin: Denken Sie darüber nach, was es bedeutet, a zu laden uint32_t aus dem Speicher in ein Register, wenn die Beendigung 0 könnte das erste Byte sein. Außerdem habe ich die eigentliche asm-Quelle für glibc’s verlinkt und erklärt strlen, die 64-Byte-Blöcke einliest. Es liest also bis zu 63 Bytes über das Ende der Zeichenfolge hinaus und verwendet 16-Byte-Vektoren.

    – Peter Cordes

    14. Juni 2016 um 5:00 Uhr


  • @DavidC.Rankin: uint32_t foo = *(uint32_t*)aligned_pointer wird zu einer 32-Bit-Last kompiliert. Es spielt keine Rolle, ob Sie nur die Bytes von testen foo eins nach dem anderen. Wenn das Verhalten Ihres Codes davon abhängt, was sich nach der Beendigung in den Bytes befindet 0, das ist ein Fehler, aber sie überhaupt zu laden, könnte ein Problem verursachen. Zugangskontrollen finden bei Lasten/Geschäften statt; keine Informationen darüber, woher die Daten stammen, werden von Registern nachverfolgt. Die strlen-Implementierung von glibc speist sogar die gesamten 64B durch die ALUs, um sie auf eine Sache zu reduzieren, auf die sie verzweigen kann.

    – Peter Cordes

    14. Juni 2016 um 5:17 Uhr

  • Danke @PeterCordes, das ist eine umfassende Antwort. Die Tatsache, dass dies bei bestehenden weit verbreiteten Implementierungen der Fall ist, verleiht der Idee viel Gewicht, dass dies auch in anderem Code in Ordnung ist (für die begrenzten Fälle, in denen es einen messbaren Unterschied macht).

    – BeeOnRope

    16. Juni 2016 um 23:23 Uhr

  • @RossRidge: Hmm, ich denke du hast recht; Es könnte tatsächlich ein Problem damit geben, dies in C zu tun, wenn der Compiler etwas über die Array-Grenzen zur Kompilierzeit (oder Link-Zeit-Optimierung) beweisen kann. ich denken in der Praxis ist es immer sicher, aber vielleicht nur mit Vektorlasten, da __m128i usw. sind in gcc/clang als may_alias definiert. Ich würde gerne von einem Compiler-internen Experten hören, ob meine möglicherweise übertrieben selbstsicheren Behauptungen richtig sind.

    – Peter Cordes

    19. Juni 2016 um 4:49 Uhr

  • Wenn Sie ein Array mit bekannter Länge haben, ist es meiner Meinung nach normalerweise am besten, die letzten Elemente mit einer nicht ausgerichteten Last zu behandeln, die sowieso am Ende stoppt. In der Praxis sollte dies meiner Meinung nach nur in Fällen erfolgen, in denen die Anzahl der Iterationen zu Beginn der Schleife nicht bekannt ist, sodass der Compiler sowieso nichts beweisen kann.

    – Peter Cordes

    19. Juni 2016 um 4:51 Uhr

  • @BeeOnRope Im Allgemeinen dürfen nur die Komponenten des Betriebssystems und des Kernelmodus diese Art von Zuordnung erstellen, aber es gibt mehrere Pfade, in denen eine Komponente im Kernelmodus die zugeordnete Region an den Benutzermodus übergibt. Zum Beispiel, KUDA tut dies und führt aus ähnlichen Leistungsgründen wie auf der CPU-Seite normalerweise keine Begrenzungsprüfung bei Zugriffen durch. Der Zugriff vom Ende löst a aus Gerät Seitenfehler, der normalerweise schlimmer ist als ein Prozessseitenfehler und das Betriebssystem oft nicht wiederherstellbar macht. Ich bin mir jedoch nicht sicher, was CUDA speziell angeht.

    – Elchjungen

    14. Juni 2016 um 0:36 Uhr


  • Das scheint ein Betriebssystemfehler zu sein, wenn es eine Zuordnung zum Benutzerbereich so übergibt, dass der Benutzermodusprozess einen Zugriff ausführen kann, der das gesamte System zum Absturz bringt. Unabhängig davon, was die C-Spezifikation über undefiniertes Verhalten sagt, sollten Betriebssysteme nicht zulassen, dass Code im Benutzermodus nicht behebbare Fehler auf Systemebene verursacht. Alles Undefinierte sollte auf den Prozess beschränkt werden.

    – Barmar

    14. Juni 2016 um 0:49 Uhr

  • @Barmar: Es kommt immer wieder vor, dass ausreichend privilegierte User-Mode-Programme direkten Zugriff auf Hardware bekommen, was sicherlich ausreicht, um das System zum Absturz zu bringen. man 2 iopl auf einer Linux-Box, wenn Sie herumspielen möchten. X-Server wären wahrscheinlich unbrauchbar langsam, wenn sie dies nicht tun würden. (Oder für einen würdevolleren Weg für ein Userspace-Programm, das System zum Absturz zu bringen, man 2 shutdown.)

    – Nate Eldredge

    14. Juni 2016 um 1:07 Uhr


  • Ja, nachdem ich das gepostet hatte, wurde mir klar, dass der Vorgang zum Erhalten des direkten Zugriffs vermutlich auf privilegierte Benutzer oder Anwendungen beschränkt ist und von ihnen erwartet wird, dass sie sicher sind (da ein privilegierter Benutzer auch Dinge wie das Herunterfahren des Systems tun kann).

    – Barmar

    14. Juni 2016 um 1:09 Uhr

  • @NateEldredge: IIRC, iopl ist nur für die Verwendung von in / out Anweisungen. Die meisten modernen Hardwaregeräte verwenden für den größten Teil ihrer Schnittstelle speicherabgebildete E/A, und Software erhält durch Speicherzuordnung Zugriff darauf /dev/mem auf Linux. Aber ja, User-Space-Software kann direkt auf Hardware zugreifen und tut dies auch.

    – Peter Cordes

    14. Juni 2016 um 1:10 Uhr

1402940cookie-checkIst es sicher, auf x86 und x64 über das Ende eines Puffers innerhalb derselben Seite hinaus zu lesen?

This website is using cookies to improve the user-friendliness. You agree by using the website further.

Privacy policy