|
|
|
Typsichere Format-StringsEin Beitrag zur Rehabilitierung von printf.
printf() in CDie Möglichkeit, Strings mit formatierten Parametern ausgeben, ist grundlegender Bestandteil praktisch jeder etablierten Hochsprachenumgebung. In C war dafür die printf()-Funktion in all ihren Varianten zuständig. In ihrer Flexibilität und Simplizität diente die Funktion als Vorbild für die Äquivalente der meisten Sprachen, ob in PHP, Delphi, C#/.NET oder Java. Jedoch hat printf() in C einen gravierenden Nachteil: die Funktion ist nicht typsicher. Der Format-String wird erst zur Laufzeit analysiert, doch verfügt printf() nicht über Typinformationen zu den Argumenten und muß daher annehmen, daß deren Typen den im Format-String angegebenen Typen entsprechen. Dies ist eine obskure und unter Umständen schwer auffindbare Fehlerquelle, im Extremfall läßt sie sich sogar zum Einschleusen von Fremdcode mißbrauchen. Es folgen einige eher harmlose Beispiele: Darüber hinaus sind die Varianten von printf() selbst bei korrekter Parameterisierung anfällig für weitere Fehler. Insbesondere können sprintf(), swprintf() und alle Varianten von scanf() mit Leichtigkeit Pufferüberläufe verursachen: Die C++-Alternative: StreamsIn den meisten Programmiersprachen, die das printf-Konzept im Grundsatz übernommen haben, sind die Formatierungsfunktionen jedoch weder anfällig für Pufferüberläufe (durch das dynamische String-Management) noch fehlt ihnen die Typsicherheit, da sie das Problem nicht wie in C über eine variable Anzahl von Stackargumenten, sondern gewöhnlich mit Arrays der einen oder anderen Art lösen. Die Entwickler der Sprache C++ jedoch wählten einen vollkommen unterschiedlichen Ansatz. Zwar sind die herkömmlichen C-Funktionen in C++ sämtlich nutzbar, und es wäre auch mit den neuen Sprachmitteln leicht möglich gewesen, die Pufferüberläufe durch die konsequente Nutzung einer String-Klasse zu vermeiden. Doch hatte das Konzept der IO-Streams, das eine sehr einfache Erweiterbarkeit für eigene Datentypen bietet, den Eindruck gestärkt, printf() sei durch seine fehlende Erweiterbarkeit ohnehin nicht mehr modern genug für die Sprache. So werden denn printf() und die verwandten Funktionen in C++ weithin als veraltet angesehen; die verschiedenen Stream-Typen sind die bevorzugte Lösung für die Formatierung. Sie kombinieren Typsicherheit und Erweiterbarkeit mit der Flexibilität von printf(): man kann dank ADL die Stream-Operatoren für eigene Klassen überladen, mittels Manipulatoren Streams formatieren, beliebige Objekte als Streams verwenden (z.B. Dateistreams, Standard-I/O-Streams, Stringstreams) und so weiter und so fort. Das Problem ist nur: Es sieht immer furchtbar aus. Man betrachte die Übertragung eines konzisen Beispieles von C nach C++: Aber selbst in weniger extremen Fällen ist augenfällig, wie schwer die Stream-Variante es beispielsweise macht, Strings vernünftig zu internationalisieren. Als Beispiel betrachten wir eine Compiler-Fehlermeldung, die wir mit gettext lokalisieren: Anstelle eines einzigen Strings mit Platzhaltern darin haben wir nun mehrere Teilstrings. Das freut den Übersetzer, ganz besonders dann, wenn er über keine oder nur vage Kontextinformationen verfügt. Noch problematischer wird es, wenn man sich die deutsche Übersetzung ansieht:E2094: Operator 'operator' ist im Typ 'typ' für Argumente des Typs 'typ' nicht implementiert Im deutschen Zustandspassiv wird das Partizip dem zugehörigen Substantiv üblicherweise nachgestellt. Das ist aber mit einer Internationalisierung obiger Einzelteile mit gettext schlicht nicht möglich. Auch darüber hinaus gibt es gute Gründe, C++-Streams bei der Formatierung von Strings aus dem Wege zu gehen. Eine sehr flexible Alternative ist die Boost Format library, die tatsächlich die meisten Vorteile der beiden Varianten kombiniert - und darüber hinaus das eine oder andere Defizit des klassischen printf() beseitigt: beispielsweise ermöglicht es, Parameter mehrfach zu verwenden und die Reihenfolge zu verändern. CbdeFormat: printf() typsicherMit boost::format() existiert zwar eine schöne Lösung, die ich nachdrücklich empfehle, doch nicht immer kann sie eingesetzt werden, beispielsweise, wenn man die Abhängigkeit eines Projektes von Boost vermeiden möchte, oder wenn größere Mengen vorliegenden Codes, der weiterer Wartung und Migration bedarf, bereits ausgiebig die printf-Syntax benutzt. Interessanterweise ist es in durchaus möglich, die oben aufgezählten Probleme systematisch zu vermeiden. Zunächst kann die Speicherverwaltung automatisiert werden, um die Gefahr von Pufferüberläufen bei sprintf() zu beseitigen: Leider existiert kein derartiges Konstrukt in der Standardbibliothek. Wie auch in vielen anderen Fällen fehlender grundlegender Library-Features bieten die meisten 3rd-Party-Bibliotheken jedoch eine entsprechende Implementation: Beispiele sind die C++Builder häufig genutzte Variante String::sprintf(), analog existiert CString::Format() in ATL/MFC. (Darüber hinaus existiert in C++Builder auch SysUtils::Format() und String::Format(), die allerdings die abweichende Delphi-Formatsyntax benutzen, zudem in C++-Code nicht maximal effizient sind. Dafür sind sie ohne zusätzlichen Aufwand typsicher.)Dabei bleibt zunächst das Problem der Typsicherheit. Zwar bietet der GCC die Option -Wformat, die ihn veranlaßt, Format-Strings zur Kompilierzeit zu überprüfen - doch sobald man den Format-String über eine Indirektion wie beispielsweise gettext() gewinnt, funktioniert das natürlich nicht mehr. Die neuen Sprachmittel von C++, insbesondere Templates und Typinferenz, ermöglichen allerdings eine (laufzeit-)typsichere Implementation der printf-Funktionen. Meine Implementation für C++Builder, CbdeFormat, möchte ich im Weiteren vorstellen. CbdeFormat funktioniert mit C++Builder 2006, 2007 und 2009 (frühere Compilerversionen beherrschen keine Variadic Macros). Obgleich ich es für C++Builder geschrieben und mit C++Builder getestet habe, ist es aber nicht abhängig von Compiler-Spezifika. Im Grunde dürfte es auch mit jedem anderen C++-Compiler funktionieren. Die Implementation erfüllt die meisten meiner Anforderungen an eine typsichere Implementation der printf-Familie:
Natürlich hat sie auch Nachteile:
Die Bibliothek kann in der C++Builder-Sektion heruntergeladen werden. Der mitgelieferte Installer integriert CbdeFormat auf Wunsch in C++Builder 2006, 2007 und 2009. Dabei führt er folgende Schritte durch:
Sodann ist CbdeFormat installiert, einsatzbereit und darüber hinaus für Debug-Builds standardmäßig aktiviert. Im Weiteren betrachte ich einige mehr oder weniger praxisnahe Anwendungsbeispiele. Beispiel 1: Migration nach C++Builder 2009/UnicodeGerade bei der anstehenden Migration nach C++Builder 2009 beginnt die fehlende Typsicherheit von Format-Strings sich gerne bemerkbar zu machen. Betrachten wir ein exemplarisches Stück Code, das sich in ähnlicher Weise in vielen C++Builder-Anwendungen befinden dürfte: Öffnet man ältere Projekte in C++Builder 2009, so setzt die IDE beim Konvertieren das TCHAR-Mapping auf "char". Das hat zur Folge, daß weder UNICODE noch _UNICODE definiert sind und daher auch sämtliche Windows-Funktionen auf die ANSI-Varianten verweisen. Da in C++Builder anders als in Delphi meist explizit der Typ AnsiString anstelle von String benutzt wird und darüber hinaus die meisten nicht-VCL-spezifischen Funktionen nicht mit wchar_t-, sondern mit char-Strings arbeiten, vereinfacht das die Migration älterer Projekte etwas. (Soll eine Anwendung aber Unicode-fähig gemacht werden, so ist es natürlich sinnvoll, das Mapping auf "wchar_t" umzustellen und den Code anzupassen.) In Delphi verläuft die Umstellung auf Unicode allerdings etwas anders als in C++Builder. Delphi-Programmierer verwendeten selten direkt AnsiString, sondern gewöhnlich den String-Typen. Dieser wurde in der Vergangenheit schon einmal geändert, nämlich von dem auf 255 Zeichen begrenzten ShortString, der bis Delphi 1 verwendet wurde, auf den referenzgezählten, dynamisch allozierten AnsiString. (Dieser Zusammenhang ist auch dafür verantwortlich, daß alle Delphi-Stringtypen nicht wie sonst üblich ab 0, sondern ab 1 indiziert werden: da die Entwickler von Turbo Pascal die Probleme der in C üblichen ASCIIZ-Strings und die umständliche String-Speicherverwaltung vermeiden wollten, wurde einfach im Index 0 des Strings die Stringlänge untergebracht, was dessen Länge effektiv auf 255 Zeichen beschränkte. In der DOS-Zeit war das ein tragbarer Kompromiß, in Win32 nicht mehr.) Da ab Delphi 2009 die Typen String und Char aber UTF-16-basiert sind, ist die Unicode-Migration deutlich weniger aufwendig. Problematisch sind hier eher subtilere Dinge, etwa die Annahme, daß Char immer ein Byte groß sei, Zeigerarithmetik mit PChar und das Unterbringen von binären Daten in Strings (was in Delphi eine Zeitlang nicht unüblich war, da erst Delphi 4 dynamische Arrays einführte). Obiges Codebeispiel jedenfalls kommt real existierendem C++Builder-Code recht nahe und eignet sich daher, um die Migration mithilfe der Unterstützung von CbdeFormat beispielhaft zu erläutern. Aufgrund des TCHAR-Mappings wird obiger Code zwar fehlerfrei übersetzt, jedoch tut er nicht das Gewünschte, denn von beiden Strings ist nur der erste Buchstabe sichtbar: ![]() Also installieren wir CbdeFormat und kompilieren das Programm neu. Nun ist CbdeFormat aktiv und überprüft sämtliche Format-Strings auf Fehler. Führen wir obigen Code dann erneut aus, so wirft CbdeFormat eine Exception, und wir sehen diesen Dialog: ![]() Nach dem Hinzufügen von SystemCppException wird die Fehlermeldung schon etwas informativer: ![]() Hieraus entnehmen wir, daß der erste Format-String-Parameter offenbar von Typ wchar_t* ist, obgleich er char* hätte sein müssen. (Auch darüber hinausgehende Abweichungen sind festzustellen, aber CbdeFormat berücksichtigt bei der Typprüfung ein gewisses Toleranzschema, beispielsweise akzeptiert es den impliziten const_cast<> oder die Umwandlung zwischen verschieden typisierten, aber auf gleiche Weise übergebenen Integerwerten wie int und unsigned long.) Grund für die Diskrepanz ist, daß unabhängig von irgendwelchen Einstellungen in unserem C++Builder-Projekt aller Delphi-Code, auch die VCL, nun standardmäßig Unicode-Strings verwendet. Auch das von uns verwendete TEdit::Text ist mittlerweile nicht mehr AnsiString, sondern UnicodeString. Das Problem läßt sich umgehen, indem wir entweder den Format-String anpassen und "%s" durch "%ls" ersetzen oder aber das Argument explizit nach AnsiString casten: AnsiString (EdtUserName->Text).c_str (). Beheben wir das Problem, so werden wir sogleich auf das nächste aufmerksam gemacht: ![]() Tatsächlich sah ich schon hin und wieder, daß Strings direkt an sprintf() übergeben wurden. Das funktioniert zwar zufällig, weil AnsiString und UnicodeString entsprechend implementiert sind (die Klassen sind nur vier Bytes groß und enthalten einen Zeiger auf den nullterminierten String), aber verwenden sollte man es trotzdem nicht - man verläßt sich auf ein Implementationsdetail, für das mit String::c_str() eine wohldokumentierte Alternative existiert. Auch diesen Fehler beseitigen wir genau wie oben, und schon funktioniert alles: ![]() Beispiel 2: Debuggen komplizierterer Format-StringsKürzlich verwendete ich in einer Anwendung, die ich in C++Builder 2009 schrieb, etwa folgendes: Hier ist die Situation etwas unübersichtlich. image ist beispielsweise vom Typ ImageManager*, einer Klasse, die Bilder speichert und verwaltet. ImageManager verwendet einige Klassen aus der Delphi-RTL und daher aus Konsistenzgründen System::String als String-Typ. ppimage hingegen ist vom Typ pp::Image&. Diese Klasse speichert primär die Pixeldaten des Bildes in einem double-Array und bietet Methoden zum Laden und Speichern, sie ist in portablem C++ implementiert und benutzt dementsprechend std::string. Weiter gibt ppimage.getXLength() einen double-Wert (in ppimage.getXUnit()) zurück, ppimage.getWidth() aber ein unsigned int für die Breite in Pixeln. Demensprechend sehen auch die Ergebnisse aus: ![]() Ist CbdeFormat aktiviert, so erhält man bei jedem fehlerhaften printf-Aufruf eine Exception mit der zugehörigen Meldung. Das ist für Einzelfälle gut geeignet, aber in diesem Falle wäre es so beispielsweise nötig, das Programm fünfmal neu zu erstellen und wieder zu starten, damit alle fehlerhaften Zeilen ausgeführt werden. Da dies etwas zu umständlich ist, behelfen wir uns damit, einen anderen Error-Handler zu registrieren. In format_error.hpp gibt es dafür folgende Schnittstellen: Der Debug-Handler ist für diesen Fall geeignet. Wir installieren ihn folgendermaßen:Nun erhalten wir eine Meldung, die es ermöglicht, den Fehler im Quelltext zu korrigieren, den Programmablauf aber fortzusetzen:![]() Wir können also, sofern sie nicht zu schwerwiegend sind, die Fehler ohne Neukompilierung beheben. Sodann sieht die Ausgabe aus wie erwartet: ![]() Beispiel 3: Übersetzer-FehlerLokalisierungen mit gettext haben unter anderem den Vorteil der einfachen Erweiterbarkeit: jeder Nutzer des Programmes kann poEdit herunterladen und die Ressourcendateien für seine eigene Sprache anpassen. Dabei passieren natürlich unvermeidlich Fehler, etwa wird der folgende, obenstehendem Beispiel entnommene String "E2094: Operator '%s' not implemented in type '%s' for arguments of type '%s'" zu "E2094: Operator '%s' ist im Typ '%s' für Argumente '%s' des Typs '%s' nicht implementiert". Daher ist es in diesen Fällen sinnvoll, auch in der Release-Version der Anwendung Format-String-Überprüfungen durchzuführen. Diese können im Release-Build durch die Definition der Präprozessorkonstante CBDE_FORMAT_CHECK_DEBUG (mit Positionsinformation, also Dateiname, Zeilennummer und Funktion) oder CBDE_FORMAT_CHECK (ohne Positionsinformation) aktiviert werden. Aktiviert man CBDE_FORMAT_CHECK, so verursacht die fehlerhafte Übersetzung eine Exception mit folgender Fehlermeldung: ![]() Dabei ist zu beachten, daß sowohl der zusätzlich generierte Code als auch der Laufzeit-Overhead äußerst gering sind. Der Format-String-Parser vergrößert die ausführbare Datei um etwa 2 KB; sofern im Format-String keine Fehler gefunden werden, kommt er ohne Heap-Allokationen aus. Auch der zusätzlich generierte Code ist minimal. Diesen Code generiert BCC ohne Format-String-Überprüfung: Mit CBDE_FORMAT_CHECK wird folgendes generiert: Das ist äquivalent zu folgendem C++-Code, mithin praktisch optimal:Für eigene Funktionen einsetzenDer Installer konfiguriert CbdeFormat für eine fixe Menge von Format-String-Funktionen. Es ist aber leicht möglich, diese Menge für eigene Format-Funktionen beliebig anzupassen. Ich will dies hier beispielhaft für obige str_printf()-Funktion zeigen:
Referenzen[1] Wikipedia: Format string vulnerabilities, zum Stand am 08.06.2009 [2] Boost Format library [3] Using the GNU Compiler Collection (GCC): Options to Request or Suppress Warnings (-Wformat) [4] Joel Spolsky: Back to Basics, 11.12.2001 Kommentare |