[T] Lesen/Schreiben von (verschlüsselten) Zip-Archiven
-
1 Einleitung
Die wenigsten (Spiele-)Entwickler möchten, dass die Daten ihres Programms (Bilder, 3D-Modelle, Sounds, Musikstücke, …) für jeden einsehbar und veränderbar sind. Dieser Artikel zeigt, wie man mit einfachen Mitteln Dateien aus verschlüsselten Zip-Archiven laden kann. Anschließend werde ich noch darauf eingehen, wie man den schreibenden Zugriff realisieren kann.
Natürlich wäre es auch möglich, sich sein eigenes Archivformat und die benötigten Tools zum Packen und Entpacken zu schreiben, aber wozu das Rad neu erfinden? Die Kompressionsrate von Zip reicht in den meisten Fällen aus, und es gibt gute Tools wie Sand am Meer.2 Was wird benötigt?
Alles, was zusätzlich zu einem Compiler noch benötigt wird, ist die völlig kostenlose zlib. Diese ist auf dieser Seite zu bekommen. Entweder lädt man sich eine bereits vorkompilierte Version der zlib herunter, oder man kompiliert sie selbst. Die Packages kommen zusammen mit Projektdateien für alle gängigen Compiler inklusive Visual C++, das manuelle Kompilieren ist also ausgesprochen einfach.
In der zlib enthalten ist auch gleichzeitig die Bibliothek minizip, die später noch benötigt wird.3 Die Funktion readArchivedFile
In diesem Artikel soll lediglich eine einzige Funktion programmiert werden. Diese zeigt, wie eine Datei aus einem verschlüsselten Zip-Archiv gelesen werden kann, und sie könnte in dieser oder ähnlicher Form später beispielsweise in einem virtuellen Dateisystem eingesetzt werden. Da dies nur der Demonstration dient, verzichte ich auf den Einsatz von Klassen. Die Funktion soll wie folgt aussehen:
int readArchivedFile(const std::string& archiveFilename, const std::string& filename, const std::string& password, void** pp_dataOut, unsigned int* p_dataSizeOut);
Im ersten Parameter werden wir den Dateinamen des Zip-Archivs übergeben (z.B. „data.zip“), im zweiten den Namen der darin archivierten Datei (z.B. „sprites.png“) und im dritten ein Passwort, falls das Archiv verschlüsselt ist. Im vierten Parameter übergeben wir die Adresse eines Zeigers, den die Funktion ausfüllt, so dass er anschließend auf die gelesenen Daten zeigt, und der letzte Parameter ist die Adresse eines ganzzahligen Werts, den die Funktion mit der Größe der gelesenen Daten ausfüllt. Für das spätere Löschen der gelesenen Daten mit delete[] ist der Programmierer selbst zuständig. Der Rückgabewert der Funktion zeigt an, ob alles glatt gelaufen ist, oder ob es einen Fehler gab.
3.1 Öffnen des Archivs
Zuerst müssen wir das Archiv öffnen. Dazu existiert die Funktion unzOpen, die – wie alle im folgenden verwendeten Funktionen – in der minizip-Library zu finden ist. Die minizip-Library befindet sich im Ordner contrib/minizip des zlib-Packages. Insbesondere benötigen wir die Funktionen aus der Datei unzip.c. In unzip.h befindet sich die Dokumentation der hier verwendeten Funktionen. Diese Datei muss per #include eingebunden werden. Man sollte neben unzip.c auch die anderen .c-Dateien aus dem minizip-Verzeichnis zum Projekt hinzufügen – außer minizip.c und miniunz.c, denn dabei handelt es sich um eigenständige Programme (zum Packen und Entpacken per Kommandozeile).
unzOpen erwartet lediglich den Dateinamen des zu öffnenden Archivs und liefert einen Rückgabewert vom Typ unzFile. Dies ist ein Handle, das wir für alle weiteren Operationen mit dem geöffneten Archiv brauchen werden. Gab es einen Fehler, ist der Rückgabewert null.3.2 Die Datei aufspüren
Als nächstes müssen wir den internen Lesezeiger des geöffneten Archivs auf die angeforderte archivierte Datei setzen. Dazu verwenden wir die Funktion unzLocateFile. Sie erwartet als Parameter das Archiv-Handle und den Namen der archivierten Datei, die später gelesen werden soll. Der dritte Parameter gibt an, ob bei der Suche der Datei die Groß-/Kleinschreibung beachtet werden soll. Ein Rückgabewert von UNZ_OK signalisiert, dass es keine Fehler gab, also die Datei gefunden wurde.
3.3 Dateiinformationen abfragen
Jetzt sind wir schon fast so weit, dass wir die Datei tatsächlich lesen können. Aber eines fehlt noch, denn zuvor sollten wir wissen, wie groß die Datei eigentlich ist, damit wir die richtige Speichermenge anfordern können. Diese und andere Informationen erhalten wir mit Hilfe der Funktion unzGetCurrentFileInfo. Diese erwartet eine Vielzahl von Parametern, wovon für uns aber nur die ersten beiden interessant sind: das Archiv-Handle (wie immer) und einen Zeiger auf eine Variable vom Typ unz_file_info. Die Funktion wird diese Variable ausfüllen, und dort finden wir dann im Element uncompressed_size die Größe der entpackten Datei.
3.4 Dekomprimieren und Entschlüsseln
Nun können wir den benötigten Speicher reservieren und sind bereit für den entscheidenden Schritt: das Öffnen und Lesen der Datei. Hierzu verwenden wir entweder unzOpenCurrentFile oder unzOpenCurrentFilePassword, abhängig davon, ob wir es mit einer verschlüsselten Datei zu tun haben oder nicht. Als Parameter brauchen wir nur das Archiv-Handle und bei der zweiten Variante noch das Passwort zum Entschlüsseln zu übergeben. Auch hier zeigt ein Rückgabewert von UNZ_OK an, dass kein Fehler aufgetreten ist.
Das eigentliche Lesen der Daten erfolgt nun mit Hilfe der Funktion unzReadCurrentFile. Dieser übergibt man das Archiv-Handle, die Adresse zum Ablegen der gelesenen Daten und die Anzahl der Bytes, die gelesen werden soll. Stimmt der Rückgabewert mit der Anzahl der angeforderten Bytes überein, war alles in Ordnung.
Nun müssen wir nur noch aufräumen, wozu wir unzCloseCurrentFile und unzClose aufrufen. Das war’s! Und hier kommt die Funktion, die genau das macht, was in den letzten Absätzen beschrieben wurde.3.5 Die ganze Funktion
int readArchivedFile(const std::string& archiveFilename, const std::string& filename, const std::string& password, void** pp_dataOut, unsigned int* p_dataSizeOut) { // Archiv öffnen unzFile archive = unzOpen(archiveFilename.c_str()); if(!archive) { // Fehler! Die Archivdatei existiert wahrscheinlich nicht oder ist beschädigt. return -1; } // die archivierte Datei aufspüren, Groß-/Kleinschreibung ignorieren int result = unzLocateFile(archive, filename.c_str(), 0); if(result != UNZ_OK) { // Fehler! Die Datei wurde wohl nicht gefunden. unzClose(archive); return -2; } // Dateiinformationen abfragen unz_file_info info; unzGetCurrentFileInfo(archive, &info, 0, 0, 0, 0, 0, 0); // die entsprechende Menge an Speicher reservieren unsigned int fileSize = static_cast<unsigned int>(info.uncompressed_size); char* p_data = new char[fileSize]; if(!p_data) { // Fehler! Nicht genug Speicher. // Es ist aber wahrscheinlicher, dass das Archiv beschädigt ist. unzClose(archive); return -3; } // Datei öffnen - mit Passwort oder ohne if(password.empty()) result = unzOpenCurrentFile(archive); else result = unzOpenCurrentFilePassword(archive, password.c_str()); if(result != UNZ_OK) { // Fehler! Das Passwort ist wahrscheinlich falsch. delete[] p_data; unzClose(archive); return -4; } // die komplette Datei lesen unsigned int numBytesRead = unzReadCurrentFile(archive, p_data, fileSize); if(numBytesRead != fileSize) { // Fehler! Das Archiv könnte beschädigt sein. delete[] p_data; unzCloseCurrentFile(archive); unzClose(archive); return -5; } // aufräumen unzCloseCurrentFile(archive); unzClose(archive); // Daten und Größe zurückliefern if(pp_dataOut) *pp_dataOut = p_data; if(p_dataSizeOut) *p_dataSizeOut = fileSize; // Alles OK! return 0; }
3.6 Anwendungsbeispiel
Ich will hier noch ein kleines Beispiel für die Verwendung der Funktion readArchivedFile zeigen. Es soll die Datei „test.txt“ aus dem Archiv „test.zip“ gelesen werden. Die Datei wurde mit dem Passwort „wurstmolekül“ verschlüsselt.
void* p_text = 0; unsigned int textSize = 0; // verschlüsselte Datei aus dem Archiv lesen int result = readArchivedFile("test.zip", "test.txt", "wurstmolekül", &p_text, &textSize); if(result) { // Fehler! printf("Fehler! (Code: %d)\n", result); } else { // Text ausgeben std::string text(static_cast<char*>(p_text), textSize); printf("Der gelesene Text: %s\n", text.c_str()); // Speicher wieder freigeben delete[] p_text; }
4 Und was ist mit Schreiben?
Die minizip-Bibliothek ermöglicht nicht nur das Lesen aus passwortverschlüsselten Zip-Archiven, sondern unterstützt auch Schreibvorgänge darin. Die entsprechenden Funktionen befinden sich in der Datei zip.c (Dokumentation in zip.h) und lassen sich ganz analog zu den hier benutzten Lesefunktionen verwenden.
Aufwändiger wird es, wenn eine bereits vorhandene Datei in einem Archiv überschrieben werden soll. Dann ist es zunächst erforderlich, die Datei komplett aus dem Archiv zu löschen. Leider bietet die minizip-Bibliothek hierzu keine Funktion. Darum möchte ich hier eine solche zur Verfügung stellen. Die Funktion deleteArchivedFile erwartet als Parameter den Dateinamen des Archivs und den Namen der zu löschenden Datei, die darin archiviert ist. Dazu wird das Archiv Datei für Datei in ein neues Archiv kopiert, wobei die zu löschende Datei einfach ausgelassen wird. Für den Fall, dass die zu löschende Datei die einzige Datei in dem Archiv ist, wird das gesamte Archiv gelöscht, da ein leeres Archiv beim Öffnen zu Problemen führt.
Die Informationen zum Aufbau des Zip-Dateiformats habe ich diesem Text entnommen.#pragma pack(push, 1) int deleteArchivedFile(const std::string& archiveFilename, const std::string& objectName) { int result = 0; struct LocalFileHeader { unsigned int signature; unsigned short versionNeeded; unsigned short flags; unsigned short method; unsigned short modTime; unsigned short modDate; unsigned int crc; unsigned int compressedSize; unsigned int uncompressedSize; unsigned short filenameLength; unsigned short extraFieldLength; }; struct EndOfCentralDirectory { unsigned int signature; unsigned short thisDisk; unsigned short centralRecordDisk; unsigned short entriesOnThisDisk; unsigned short totalEntries; unsigned int centralDirectorySize; unsigned int centralDirectoryOffset; unsigned short globalCommentLength; }; struct CentralDirectoryEntry { unsigned int signature; unsigned short versionMadeBy; unsigned short versionNeeded; unsigned short flags; unsigned short method; unsigned short modTime; unsigned short modDate; unsigned int crc; unsigned int compressedSize; unsigned int uncompressedSize; unsigned short filenameLength; unsigned short extraFieldLength; unsigned short commentLength; unsigned short diskNumber; unsigned short intAttribs; unsigned int extAttribs; unsigned int localHeaderOffset; }; FILE* p_in = fopen(archiveFilename.c_str(), "rb"); FILE* p_out = fopen((archiveFilename + "_").c_str(), "wb"); // zentrales Verzeichnis suchen while(true) { unsigned int signature; unsigned int pos = ftell(p_in); fread(&signature, 1, 4, p_in); fseek(p_in, pos, SEEK_SET); if(signature == 0x04034B50) { LocalFileHeader lfh; fread(&lfh, 1, sizeof(lfh), p_in); fseek(p_in, lfh.filenameLength + lfh.extraFieldLength + lfh.compressedSize, SEEK_CUR); } else if(signature == 0x02014B50) { CentralDirectoryEntry cde; fread(&cde, 1, sizeof(cde), p_in); fseek(p_in, cde.filenameLength + cde.extraFieldLength + cde.commentLength, SEEK_CUR); } else if(signature == 0x06054B50) { // Danach haben wir gesucht! break; } else { return false; } } EndOfCentralDirectory ecd, ecdOut; fread(&ecd, 1, sizeof(ecd), p_in); char* p_globalComment = 0; if(ecd.globalCommentLength) p_globalComment = new char[ecd.globalCommentLength]; ecdOut = ecd; fseek(p_in, ecd.centralDirectoryOffset, SEEK_SET); std::vector<CentralDirectoryEntry> cdOut; std::vector<char*> filenameOut, extraFieldOut, commentOut; // Einträge lesen und schreiben for(unsigned int i = 0; i < ecd.totalEntries; i++) { CentralDirectoryEntry cde, cdeOut; fread(&cde, 1, sizeof(cde), p_in); cdeOut = cde; char* p_filename = new char[cde.filenameLength + 1]; fread(p_filename, 1, cde.filenameLength, p_in); p_filename[cde.filenameLength] = 0; char* p_extraField = 0; if(cde.extraFieldLength) { p_extraField = new char[cde.extraFieldLength]; fread(p_extraField, 1, cde.extraFieldLength, p_in); } char* p_comment = 0; if(cde.commentLength) { p_comment = new char[cde.commentLength]; fread(p_comment, 1, cde.commentLength, p_in); } // merken, wo es nachher weitergeht unsigned int nextCDE = ftell(p_in); // Stimmt der Dateiname mit dem zu löschenden Dateinamen überein? if(!_stricmp(objectName.c_str(), p_filename)) { ecdOut.entriesOnThisDisk--; ecdOut.totalEntries--; ecdOut.centralDirectorySize -= sizeof(cde) + cde.filenameLength + cde.extraFieldLength + cde.commentLength; result = 1; delete[] p_filename; delete[] p_extraField; delete[] p_comment; } else { // Diese Datei soll kopiert werden. Zuerst lesen wir ihren lokalen Header. LocalFileHeader lfh; fseek(p_in, cde.localHeaderOffset, SEEK_SET); fread(&lfh, sizeof(lfh), 1, p_in); // Dateiname und Extrafeld überspringen fseek(p_in, lfh.filenameLength + lfh.extraFieldLength, SEEK_CUR); // Daten lesen char* p_fileData = new char[lfh.compressedSize]; fread(p_fileData, 1, lfh.compressedSize, p_in); // lokalen Header schreiben cdeOut.localHeaderOffset = ftell(p_out); fwrite(&lfh, 1, sizeof(lfh), p_out); fwrite(p_filename, 1, lfh.filenameLength, p_out); if(lfh.extraFieldLength) fwrite(p_extraField, 1, lfh.extraFieldLength, p_out); fwrite(p_fileData, 1, lfh.compressedSize, p_out); delete[] p_fileData; // Eintrag für das zentrale Verzeichnis merken cdOut.push_back(cdeOut); filenameOut.push_back(p_filename); extraFieldOut.push_back(p_extraField); commentOut.push_back(p_comment); } fseek(p_in, nextCDE, SEEK_SET); } // zentrales Verzeichnis schreiben ecdOut.centralDirectoryOffset = ftell(p_out); for(unsigned int i = 0; i < cdOut.size(); i++) { CentralDirectoryEntry& cde = cdOut[i]; fwrite(&cde, 1, sizeof(cde), p_out); char* p_filename = filenameOut[i]; char* p_extraField = extraFieldOut[i]; char* p_comment = commentOut[i]; fwrite(p_filename, 1, cde.filenameLength, p_out); if(cde.extraFieldLength) fwrite(p_extraField, 1, cde.extraFieldLength, p_out); if(cde.commentLength) fwrite(p_comment, 1, cde.commentLength, p_out); delete[] p_filename; delete[] p_extraField; delete[] p_comment; } // Ende schreiben fwrite(&ecdOut, 1, sizeof(ecdOut), p_out); if(ecdOut.globalCommentLength) fwrite(p_globalComment, 1, ecdOut.globalCommentLength, p_out); delete[] p_globalComment; fclose(p_in); fclose(p_out); // altes Archiv löschen remove(archiveFilename.c_str()); if(ecdOut.totalEntries) { // neue Datei umbenennen rename((archiveFilename + "_").c_str(), archiveFilename.c_str()); } else { // neue Datei löschen remove((archiveFilename + "_").c_str()); result = -1; } return result; } #pragma pack(pop)
5 Weitere Anregungen
Die hier gezeigten Funktionen sind natürlich noch recht rudimentär. Richtig praktisch wird das ganze erst, wenn man es in ein virtuelles Dateisystem integriert. Dort könnte man Zip-Archive wie Verzeichnisse behandeln und beispielsweise Dateinamen wie „data.zip[passwort]/sprites.png“ verwenden. Das Dateisystem würde beim Analysieren des Pfads erkennen, dass es sich um ein verschlüsseltes Zip-Archiv handelt und dafür sorgen, dass die Datei „sprites.png“ mit dem Passwort „passwort“ entpackt wird. Das alles würde ganz transparent geschehen, so dass es dem Benutzer (Programmierer) gleich sein könnte, ob sein Pfad auf eine gewöhnliche Datei oder auf eine archivierte Datei zeigt, denn das Dateisystem würde sich um alle nötigen Schritte kümmern.
Das ursprüngliche Ziel war ja, dass niemand einfach so in die Dateien einer Anwendung hereinsehen kann. Wenn das Passwort jedoch im Klartext, also unverschlüsselt, im Programmcode steht, dann wird es ohne weitere Maßnahmen auch unverschlüsselt in der ausführbaren Datei (.exe) stehen und könnte so herausgefischt werden.
Darum empfiehlt es sich, alle Passwörter noch einmal separat zu verschlüsseln.
-
Ganz offen muss ich dir sagen, dass ich mit deinem Beitrag nicht zufrieden bin:
In Abschnitt 3.1-3.4 solltest du vielleicht den Code über den du sprichst direkt angeben, anstelle nur die komplette Funktion in 3.5
Die API halte ich für nicht gut und Fehleranfällig
int readArchivedFile(const std::string& archiveFilename, const std::string& filename, const std::string& password, void** pp_dataOut, unsigned int* p_dataSizeOut)
Du gibst "magische Zahlen" zurück, die Fehler symbolisieren wollen. Ich will dich nicht zur Benutzung von Exceptions nötigen. Aber zumindest ein enum für die Zahlen fände ich angebracht.
pp_dataOut empfinde ich ebenfalls als sehr kritisch, da sich der Benutzer um die Speicherfreigabe explizit kümmern muss. Das halte ich für sehr schlechtes Design. Für Größenangaben sollte man übrigens std::size_t benutzen.
char* p_data = new char[fileSize]; if(!p_data)
diese Code ist schlicht falsch. new wirft eine Exception, wenn kein Speicher vorhanden ist und liefert nicht 0 zurück.
Die Verwendung von Compiler abhängigen Dingen, wie
#pragma pack(push, 1)
solltest du erklären. Du kannst nicht davon ausgehen, das alle Leser deine Wahl des Compilers teilen oder überhaupt verstehen, was das macht.
Die Verwendung von FILE* in C++ halte ich nicht mehr für Zeitgemäß.
unsigned int pos = ftell(p_in); // ... fseek(p_in, pos, SEEK_SET);
hier fehlt eine Fehlerprüfung. ftell() kann auch -1 zurück geben.
fread(&signature, 1, 4, p_in);
hier haben wir wieder magische Zahlen.
if(ecd.globalCommentLength) p_globalComment = new char[ecd.globalCommentLength]; //...
Die Speicherverwaltung in dieser Funktion ist nicht Exceptionsicher, da in dem Code Exceptions geworfen werden könnten (zB beim anlegen des vectors oder bei folgenden news)
if(!_stricmp(objectName.c_str(), p_filename))
Compiler abhängige Funktion!
Darum empfiehlt es sich, alle Passwörter noch einmal separat zu verschlüsseln.
Was aber niemanden wirklich abhält.
Aber im Grunde zeigt eine Suche nach zip password recovery, dass das alles eh nicht den Aufwand wert ist. Im Grunde führst du nur eine sehr sehr schwache pseudo Sicherheit ein. Jemand der wirklich an dem Inhalt deiner ZIPs interessiert ist, hälst du damit noch nicht mal ein paar Tage auf.
-
Danke für das Feedback.
Das mit new wusste ich in der Tat nicht ... das wird mir in Zukunft nicht mehr passieren.Tools zum Herausfinden eines Zip-Passworts können aber doch nur Passwörter mit kleiner Länge in akzeptabler Zeit knacken, oder nicht (da sie immer alle Kombinationen durchprobieren)? D.h. wenn man ein einigermaßen langes und zufälliges Passwort nimmt, ist die Gefahr, dass es durch Ausprobieren gefunden gefunden wird, sehr gering.
Natürlich gibt es keine perfekte Sicherheit, aber zumindest kann man es den "Angreifern" doch schwer machen. Wir reden ja nicht von hochsensiblen Daten, sondern beispielsweise von Texturen oder Sounds.Vielleicht lasse ich den Code zum Löschen einer archivierten Datei lieber weg und beschreibe nur, wie man vorgehen kann? Ich weiß auch nicht, wie ich hier auf die "magische 4" oder 2 verzichten soll, wenn in der Zip-Spezifikation steht, dass gewisse Daten 4 bzw. 2 Bytes groß sind.
Wenn du grundsätzlich findest, dass der Artikel hier nicht reingehört, dann muss ich noch zu meiner Verteidigung sagen, dass es auch nicht meine Idee war, ihn hier reinzuschreiben
-
Ich habe nichts gegen den Artikel grundsätzlich. Ich finde nur, dass er zum einen falsche Hoffnungen macht und zum anderen schwere Schwächen im Sourcecode hat.
Ich weiß auch nicht, wie ich hier auf die "magische 4" oder 2 verzichten soll, wenn in der Zip-Spezifikation steht, dass gewisse Daten 4 bzw. 2 Bytes groß sind.
ui, da machst du übrigens noch einen viel schlimmeren Fehler. Du gehst davon aus, dass die Typen eine fixe größe haben, was aber nicht stimmt!
auf die magischen Zahlen kannst du dann durch ein sizeof verzichten
-
Was wäre denn da eine angebrachte Lösung?
Soll man für jede bekannte Plattform per typedef und mit einer Menge von #ifdef die Typen byte, word, dword definieren?
Oder soll ich diese Typen einfach im Code verwenden und als Anmerkung dazuschreiben, dass word = 2 Bytes und dword = 4 Bytes ist, so dass man sich dann selbst die typedefs machen muss? Das ganze wird doch sonst übertrieben kompliziert für eine simple Beispielimplementierung.
-
In cstdint findest du entsprechende typedefs. Der Header ist in tr1 dazu gekommen. Für Leute mit Compiler ohne tr1-Support gibt es den auch in boost.
-
naja man könnte das abfragen mit boost::static_assert, aber das ist doch wieder müll
boot hatt sicherlich auf fixedsizetypen typedefsich stimme sonst Tomas zu, du verzichtest ja auch nicht auf einen airbag nur weil er dich nicht 100% schutz und somit nur schutz vorgaukelt
jetzt schon cstdint zu benutzen ist schlimmer als davon auszugehen das ein sizeof(int) == 4 ist
-
Oder zumindest statt der Zahl hinzuschreiben ne Konstante definieren. Das reicht auch, um die magischen Zahlen loszuwerden. Der Name der Konstanten sagt dann ja was sie bedeutet.
-
Hi,
wie sieht's bei dir aus? Kann der Artikel das nächste mal mit rausgehen?
Grüße
GPC