Überträgt std :: ptr :: write die „Uninitialisierung“ der geschriebenen Bytes?

8

Ich arbeite an einer Bibliothek, mit deren Hilfe Typen abgewickelt werden können, die über FFI-Grenzen in eine Zeigergröße passen. Angenommen, ich habe eine Struktur wie diese:

use std::mem::{size_of, align_of};

struct PaddingDemo {
    data: u8,
    force_pad: [usize; 0]
}

assert_eq!(size_of::<PaddingDemo>(), size_of::<usize>());
assert_eq!(align_of::<PaddingDemo>(), align_of::<usize>());

Diese Struktur hat 1 Datenbyte und 7 Füllbytes. Ich möchte eine Instanz dieser Struktur in eine packen usizeund sie dann auf der anderen Seite einer FFI-Grenze entpacken. Da diese Bibliothek generisch ist, verwende ich MaybeUninitund ptr::write:

use std::ptr;
use std::mem::MaybeUninit;

let data = PaddingDemo { data: 12, force_pad: [] };

// In order to ensure all the bytes are initialized,
// zero-initialize the buffer
let mut packed: MaybeUninit<usize> = MaybeUninit::zeroed();
let ptr = packed.as_mut_ptr() as *mut PaddingDemo;

let packed_int = unsafe {
    std::ptr::write(ptr, data);
    packed.assume_init()
};

// Attempt to trigger UB in Miri by reading the
// possibly uninitialized bytes
let copied = unsafe { ptr::read(&packed_int) };

Löst dieser assume_initAufruf undefiniertes Verhalten aus? Mit anderen Worten, wenn die ptr::writeStruktur in den Puffer kopiert wird , kopiert sie dann die Nichtinitialisierung der Auffüllbytes und überschreibt den initialisierten Zustand als Nullbytes?

Wenn dieser oder ein ähnlicher Code in Miri ausgeführt wird, erkennt er derzeit kein undefiniertes Verhalten. Doch je die Diskussion über dieses Thema auf Github , ptr::writeist angeblich erlaubt , dieses Füllbytes zu kopieren, und darüber hinaus ihre uninitialized-ness zu kopieren. Ist das wahr? Die Dokumente für ptr::writesprechen darüber überhaupt nicht, ebenso wenig wie der Nomicon-Abschnitt über nicht initialisierten Speicher .

Lucretiel
quelle
Einige nützliche Optimierungen können dadurch erleichtert werden, dass eine Kopie eines unbestimmten Werts das Ziel in einem unbestimmten Zustand belässt. In anderen Fällen ist es jedoch erforderlich, ein Objekt mit der Semantik kopieren zu können, zu der unbestimmte Teile des Originals werden in der Kopie nicht spezifiziert (so dass zukünftige Kopien garantiert zueinander passen würden). Leider scheinen Sprachdesigner nicht viel Wert darauf zu legen, wie wichtig es ist, die letztere Semantik in sicherheitsrelevantem Code zu erreichen.
Supercat

Antworten:

3

Hat dieser Aufruf von accept_init ein undefiniertes Verhalten ausgelöst?

Ja. "Nicht initialisiert" ist nur ein weiterer Wert, den ein Byte in der Rust Abstract Machine neben den üblichen 0x00 - 0xFF haben kann. Schreiben wir dieses spezielle Byte als 0xUU. (Weitere Informationen zu diesem Thema finden Sie in diesem Blog-Beitrag .) 0xUU wird von Kopien beibehalten, genau wie jeder andere mögliche Wert, den ein Byte haben kann, von Kopien beibehalten wird.

Die Details sind jedoch etwas komplizierter. Es gibt zwei Möglichkeiten, Daten in Rust im Speicher zu kopieren. Leider werden die Details hierfür auch vom Rust-Sprachteam nicht explizit angegeben. Was folgt, ist meine persönliche Interpretation. Ich denke, was ich sage, ist unumstritten, sofern nicht anders angegeben, aber das könnte natürlich ein falscher Eindruck sein.

Untypisierte / byteweise Kopie

Wenn ein Bereich von Bytes kopiert wird, überschreibt der Quellbereich im Allgemeinen nur den Zielbereich. Wenn der Quellbereich also "0x00 0xUU 0xUU 0xUU" war, enthält der Zielbereich nach dem Kopieren genau diese Liste von Bytes.

So verhält sich memcpy/ memmovein C (in meiner Interpretation des Standards, was hier leider nicht sehr klar ist). Führt in Rust ptr::copy{,_nonoverlapping} wahrscheinlich eine byteweise Kopie durch, die derzeit jedoch nicht genau spezifiziert ist, und einige Leute möchten möglicherweise sagen, dass sie ebenfalls eingegeben wurde. Dies wurde in dieser Ausgabe etwas diskutiert .

Typisierte Kopie

Die Alternative ist eine "typisierte Kopie", die bei jeder normalen Zuweisung ( =) und bei der Übergabe von Werten an / von einer Funktion auftritt. Eine typisierte Kopie interpretiert den Quellspeicher eines bestimmten Typs Tund "serialisiert" diesen Wert des Typs dann erneut Tin den Zielspeicher.

Der Hauptunterschied zu einer byteweisen Kopie besteht darin, dass Informationen verloren gehen, die für den Typ nicht relevant Tsind. Dies ist im Grunde eine komplizierte Art zu sagen, dass eine getippte Kopie das Auffüllen "vergisst" und es effektiv auf nicht initialisiert zurücksetzt. Im Vergleich zu einer untypisierten Kopie verliert eine getippte Kopie mehr Informationen. Nicht typisierte Kopien behalten die zugrunde liegende Darstellung bei, typisierte Kopien behalten nur den dargestellten Wert bei.

Selbst wenn Sie auf umwandeln 0usize, PaddingDemokann eine getippte Kopie dieses Werts diesen Wert auf "0x00 0xUU 0xUU 0xUU" (oder andere mögliche Bytes für das Auffüllen) zurücksetzen - vorausgesetzt, datader Offset 0 befindet sich, was nicht garantiert ist (fügen #[repr(C)]Sie hinzu, wenn Sie möchten diese Garantie).

In Ihrem Fall wird ptr::writeein Argument vom Typ verwendet PaddingDemo, und das Argument wird über eine typisierte Kopie übergeben. Bereits zu diesem Zeitpunkt können sich die Füllbytes beliebig ändern, insbesondere können sie 0xUU werden.

Nicht initialisiert usize

Ob Ihr Code UB hat, hängt dann von einem weiteren Faktor ab, nämlich ob ein nicht initialisiertes Byte in a usizeUB ist. Die Frage ist, ob ein (teilweise) nicht initialisierten Speicherbereich repräsentiert eine ganze Zahl? Derzeit ist dies nicht der Fall und somit gibt es UB . Ob dies der Fall sein sollte, wird jedoch heftig diskutiert, und es ist wahrscheinlich, dass wir dies irgendwann zulassen werden.

Viele andere Details sind jedoch noch unklar - zum Beispiel kann die Umwandlung von "0x00 0xUU 0xUU 0xUU" in eine Ganzzahl durchaus zu einer vollständig nicht initialisierten Ganzzahl führen, dh Ganzzahlen können möglicherweise die "teilweise Initialisierung" nicht beibehalten. Um teilweise initialisierte Bytes in Ganzzahlen beizubehalten, müssten wir grundsätzlich sagen, dass eine Ganzzahl keinen abstrakten "Wert" hat, sondern nur eine Folge von (möglicherweise nicht initialisierten) Bytes. Dies spiegelt nicht wider, wie Ganzzahlen in Operationen wie verwendet werden /. (Ein Teil davon hängt auch von LLVM-Entscheidungen ab poisonundfreeze ; LLVM kann entscheiden, dass beim Laden eines Integer-Typs das Ergebnis vollständig ist, poisonwenn ein Eingabebyte vorhanden istpoison.) Selbst wenn der Code nicht UB ist, weil wir nicht initialisierte Ganzzahlen zulassen, verhält er sich möglicherweise nicht wie erwartet, da die Daten, die Sie übertragen möchten, verloren gehen.

Wenn Sie Rohbytes übertragen möchten, empfehle ich, einen dafür geeigneten Typ zu verwenden, z MaybeUninit. Wenn Sie einen Integer-Typ verwenden, sollte das Ziel darin bestehen, Integer-Werte zu übertragen, dh Zahlen.

Ralf Jung
quelle
Das ist alles sehr hilfreich, danke!
Lucretiel
Wenn also das in Ihrem letzten Absatz beschriebene Verhalten formalisiert wird (derzeit nicht der Fall), kann es sein, dass eine Usize UU-Bytes enthält, solange keine Operationen daran ausgeführt werden, und dann wieder in meinen ursprünglichen Typ umgewandelt wird. Das würde funktionieren, da es keine Rolle spielt, ob die Füllbytes UU sind.
Lucretiel
Danke für die ausführliche Antwort! Wäre es Miri möglich, ein solches undefiniertes Verhalten zu erkennen?
Sven Marnach
1
@Lucretiel, wenn wir entschieden hätten, dass usizeTaschen von Bytes (und nicht Ganzzahlen) dargestellt werden, dann ja, usizeund MaybeUninit<usize>wäre äquivalent und beide würden die zugrunde liegende Darstellung auf Byte-Ebene (und dies schließt "undefinierte Bytes" ein) perfekt beibehalten.
Ralf Jung
1
@SvenMarnach Da die aktuelle Implementierung von ptr::writeintelligent genug ist, um die nicht initialisierten Tailing-Bytes nicht zu kopieren.
Lucretiel