Vorwort
In manchen Fällen möchte man Daten, die erst nach der Entwicklung des Programms vorhanden sind, dem Programm hinzufügen. (In diesem Artikel wird nur das PE32-Format behandelt!) Nun wäre es möglich das ganze Projekt neu zu kompilieren mit den Daten. Doch wie geht der Entwickler vor, wenn nur der Endbenutzer die Daten kennt und diese eintragen möchte? Sollte der Entwickler den SRC mitliefern oder muss der Endbenutzer dem Entwickler bei jeder Änderung der Daten um ein neuens Programm bitten?
Das Builder-Stub Prinzip
Die Lösung für unser Problem nennt sich auch das „Builder-Stub“-Prinzip. Dabei haben wir zwei unterschiedliche Programme. Den Builder und das Stub-Programm, wobei der Builder unsere Daten an das Stub-Programm anhängt und der Stub die Daten ausliest. Wobei wir eine Kopie der Stub erstellen und diese dann verändern. So hat man für jede Datenanhängung eine ausführbare Datei und das Stub-Programm bleibt in seinem ursprünglichen Zustand.
Bisher kenne ich drei unterschiedliche Implementierungen des Builder-Stub-Prinzips:
– EOF
– Ressource
– Manipulation der PE-Sektion
EOF
EOF steht (wie viele wohl schon vermutet haben) für End Of File. Für unseren Builder heißt dies, dass dieser die Daten an das Ende der Stub schreibt. Beim Auslesen muss man nur die genaue Byte-Anzahl kennen, oder die Anzahl vom Builder direkt nach den Daten anhängen. Somit ist es möglich Informationen unterschiedlicher Größe an eine Stub anzuhängen. Im Folgenden Beispiel habe ich mich für eine feste Anzahl an Bytes entschieden, allerdings kann man dies mit ein wenig Fantasie ändern. Außerdem wurde eine Signatur hinzugefügt, damit das Ausführen des Stub-Programms (also ohne Daten) reibungslos funktioniert.
bool WriteDataToStubEOF(const char* Data, size_t Size,std::string Signatur = "TG",std::string StubPath ="",std::string OutPath ="")
{
std::fstream writer;
if(StubPath == "" | OutPath == "")
{
char Filepath[256];
std::string Path;
GetModuleFileNameA(0,Filepath,255);
Path = Filepath;
Path = Path.substr(0,Path.find_last_of('\')+1); //'
StubPath = Path + "Stub.exe";
OutPath = Path + "Output.exe";
}
if(CopyFileA(StubPath.c_str(),OutPath.c_str(),false))
{
writer.open(OutPath.c_str(),std::ios_base::out|std::ios_base::binary|std::ios_base::app);
if(writer.good())
{
writer.write(Data,Size);
writer.write(reinterpret_cast<char*> (&Size),sizeof(size_t));
writer.write(Signatur.c_str(),Signatur.length());
writer.close();
return true;
}
else
{
MessageBoxA(0,"Output-Datei konnte leider nicht geoeffnet und beschrieben werden","Datei-Fehler",0);
}
writer.close();
return false;
}
else
{
MessageBoxA(0,"Stub konnte nicht kopiert werden","Kopier-Fehler",0);
}
}
char* ReadDataFromStubEOF(size_t& Size,std::string Signatur = "TG")
{
char* Data;
std::fstream reader;
char Filepath[256];
GetModuleFileNameA(0,Filepath,255);
reader.open(Filepath,std::ios_base::in|std::ios_base::binary);
if(reader.good())
{
char* Temp;
size_t Len;
size_t Offset;
Temp = new char[Signatur.length()];
reader.seekg(0,std::ios_base::end);
Len = reader.tellg();
reader.seekg(0,std::ios_base::beg);
Offset = Len - Signatur.length();
reader.seekg(Offset);
reader.read(Temp,Signatur.length());
if(strncmp(Signatur.c_str(),Temp,Signatur.length()) == 0)
{
Offset -= sizeof(size_t);
reader.seekg(Offset);
reader.read((char*) &Size,sizeof(size_t));
Offset -= Size;
reader.seekg(Offset);
Data = new char[Size];
reader.read(Data,Size);
return Data;
}
else
MessageBoxA(0,"Signatur stimmte nicht ueberein","Signatur-Fehler",0);
delete[] Temp;
}
else
MessageBoxA(0,"Konnte Prozessdatei nicht oeffnen","Datei-Fehler",0);
reader.close();
return 0;
}
Der SRC sollte von jedem verstanden werden. Nun muss man die Funktionen lediglich in dem Builder oder in dem Stub-Programm aufrufen. Dabei müssen natürlich die Signaturen übereinstimmen. Falls noch Fragen diesbezüglich offen sind kann die Person mir eine Mail schreiben, oder ein Kommentar verfassen.
Ressourcen
Eine ausführbare Datei unter Windows, also im PE-Format besitzt sogenannte Ressourcen.
Diese Ressourcen sind Bytes die durch eine Addresse oder durch einen eindeutigen Namen aufgelöst werden können. Dabei unterscheidet man noch zwischen diversen Ressourcen-Typen. Fast jede ausführbare Datei hat ein Icon, welches vom Windows-Explorer angezeigt wird. Genau dieses Icon wird in der Datei als Ressource gespeichert mit dem Typ „RT_ICON“. Dank der WinAPI ist es kinderleicht mit Ressourcen einer PE-Datei zu arbeiten. Außerdem brauchen wir nun keine Signatur mehr, da die Ressource als Signatur verwendet werden kann.
Bearbeiten der Ressourcen
HANDLE WINAPI BeginUpdateResource(LPCSTR pFileName,BOOL bDeleteExistingResources);
pFileName ist der Pfad zu unserer Datei als C-String und bDeleteExistingResources sollte wohl klar sein (true fürs Löschen!).
Die Funktion liefert uns einen Zeiger zu der geladenen Datei. Diesen Zeiger brauchen wir für die weiteren Funktionen.
BOOL WINAPI UpdateResource(HANDLE hUpdate,LPCSTR lpType,LPCSTR lpName,WORD wLanguage,LPVOID lpData,DWORD cbData);
hUpdate ist der Zeiger unserer Datei, den wir zuvor per BeginUpdateResource erhalten haben.
lpType und lpName identifizieren die Ressource. Wobei man anstatt einem C-String auch die Funktion MAKEINTRESOURCEA() aufrufen kann, welche ein WORD erwartet.
wLanguage bezeichnet die zu verwendende Sprache (nicht weiter behandelt).
lpData ist ein Zeiger zu unseren Daten, die wir anhängen möchten und cbData entspricht der Größe der Daten die wir als Ressource speichern möchten.
Falls lpData 0 ist, so wird die Ressource gelöscht.
BOOL WINAPI EndUpdateResource(HANDLE hUpdate,BOOL fDiscard);
hUpdate ist ein Zeiger zu unserer Datei und per fDiscard geben wir an, ob unsere Veränderungen verworfen oder beibehalten werden sollen.
Unser Builder macht nun folgendes:
Zu Beginn laden wir die Datei per „BeginUpdateResource()“ in den Speicher und erhalten einen Zeiger auf diese Datei, die wir für die weiteren API-Aufrufe benötigen. Per „UpdateResource()“ können wir nun eine neue Ressourcen hinzufügen, wobei wir einen konstanten Typ und Namen für die Ressource haben. Abschließend speichern wir die Datei und damit auch unsere Änderungen mit „EndUpdateResource()“.
Auslesen der Ressourcen
HRSRC WINAPI FindResource(HMODULE hModule,LPCTSTR lpName,LPCTSTR lpType);
hModule ist ein Zeiger zu unserer geladenen! Datei. Falls dieses 0 ist, so wird das Modul genommen, dessen Code-Segment gerade ausgeführt wird.
lpName und lpType dienen der Identifizierung der Ressource, wobei man alternativ auch MAKEINTRESOURCEA() benutzen kann.
Für unser Builder-Stub-Prinzip sollte man hier konstanten verwenden, die einmalig sind und im Build-Programm als auch im Stub-Programm gleich sind.
Der Rückgabewert wird für die weiteren Funktionen benötigt und ist ein Zeiger zu unserer gefundenen Ressource.
HGLOBAL WINAPI LoadResource(HMODULE hModule,HRSRC hResInfo);
hModule ist unser Modul (in unserem Beispiel 0) und hResInfo ist der Rückgabewert von FindResource.
Die Funktion liefert einen Zeiger zu den durch die Ressource assoziierten Daten.
LPVOID WINAPI LockResource(HGLOBAL hResData);
hResData ist der Rückgabewert von LoadResource und der Rückgabewert ist ein Zeiger zu den ersten Bytes der Ressource-Daten.
DWORD WINAPI SizeofResource(HMODULE hModule,HRSRC hResInfo);
hresInfo ist der Rückgabewert von FindResource und es wird ein DWORD mit der Größe unserer Ressource-Daten zurückgegeben.
Unserer Stub macht nun folgendes:
Zuerst erhält man per „FindResource()“ einen zeiger zu unserer Ressource-Eintrag, anschließend erhält man mithilfe der Funktion „LoadResource()“ einen Zeiger zu den assoziierten Daten der Ressource und per „LockResource()“ erhält man einen Zeiger zu den eigentlichen Ressource-Daten. Um die Größe der Ressource zu ermitteln benutzen wir die Funktion „SizeofResource()“. Anschließend alloziieren wir neuen Speicherplatz mit der Größe von SizeofResource() und kopieren dort die Daten hinein (falls wir schreibzugriff benötigen!)
Der Code
bool WriteDataToStubResource(const char* Data, size_t Size,WORD Name,WORD Type ,std::string StubPath="", std::string OutPath="")
{
if(StubPath == "" | OutPath == "")
{
char Filepath[256];
std::string Path;
GetModuleFileNameA(0,Filepath,255);
Path = Filepath;
Path = Path.substr(0,Path.find_last_of('\')+1);
StubPath = Path + "Stub.exe";
OutPath = Path + "Output.exe";
}
if(CopyFileA(StubPath.c_str(),OutPath.c_str(),false))
{
HANDLE hFile;
hFile = BeginUpdateResourceA(OutPath.c_str(),false);
if(hFile != NULL)
{
if(UpdateResourceA(hFile,MAKEINTRESOURCEA(Type),MAKEINTRESOURCEA(Name),LANG_NEUTRAL,(void*) Data,(DWORD) Size) == true)
{
if(EndUpdateResourceA(hFile,false)==true)
return true;
else
MessageBoxA(0,"Konnte Ressource nicht speichern","Ressource-Fehler",0);
}
else
MessageBoxA(0,"Konnte Ressource nicht schreiben","Ressource-Fehler",0);
}
else
MessageBoxA(0,"Konnte das Ressource-Handle nicht erzeugen","Handle-Fehler",0);
}
else
MessageBoxA(0,"Konnte Stub nicht kopieren","Kopier-Fehler",0);
return false;
}
char* ReadDataFromStubResource(size_t& Size,WORD Name, WORD Type)
{
HRSRC hResourceFound;
HGLOBAL hResourceLoaded;
LPVOID ResourceLocked;
DWORD ResourceSize;
char* Readed;
hResourceFound = FindResourceA(0,MAKEINTRESOURCEA(Name),MAKEINTRESOURCEA(Type));
if(hResourceFound != NULL)
{
hResourceLoaded = LoadResource(0,hResourceFound);
if(hResourceLoaded != NULL)
{
ResourceLocked = LockResource(hResourceLoaded);
if(ResourceLocked != NULL)
{
ResourceSize = SizeofResource(0,hResourceFound);
if(ResourceSize > 0)
{
Readed = new char[ResourceSize];
Size = ResourceSize;
memcpy(Readed,ResourceLocked,(size_t) ResourceSize);
return Readed;
}
else
MessageBoxA(0,"Groesse der Ressource ist nicht gueltig","Size-Fehler",0);
}
else
MessageBoxA(0,"Konnte Resource nicht locken","Lock-Fehler",0);
}
else
MessageBoxA(0,"Konnte Ressource nicht laden","Lade-Fehler",0);
}
else
MessageBoxA(0,"Konnte Ressource nicht finden","Keine Ressource",0);
return 0;
}
Neue Sektion
Ich hatte kurz erwähnt, dass eine PE-Datei Ressourcen besitzt und somit auch einer festen Struktur folgt.
Befassen wir uns zunächst mit den Basiswissen über eine PE-Datei, denn dieses Wissen ist durchaus ausreichend für unser Vorhaben.
Beim Ausführen einer PE-Datei legt Windows einen neuen Prozess an und alloziiert Speicher im virtuellen Speicher.
Anschließend wird die Datei an einer festen Addresse geladen, der sogenannten ImageBase. Eine PE-Datei besitzt sogenannte Sektionen. Diese Sektionen entsprechen einem Bereich im virtuellen Speicher in dem bestimmte Daten stehen. Zum Beispiel gibt es eine Sektion für die Code-Instruktionen, meistens auch TEXT-, oder CODE-Sektion genannt. Der Grund wieso man eine PE-Datei in Sektionen unterteilt ist denkbar einfach und lautet Zugriffsrechte. Warum sollte man im Bereich der Code-Sektion Daten schreiben wollen, oder warum sollte man den Bereich in dem die Ressourcen gespeichert werden als auführbar makieren?
Wichtig ist hierbei zu beachten, dass die Sektionen immer ein vielfaches von dem SectionAlignment (im Speicher) und vom FileAlignment (auf der Festplatte) sein müssen.
Doch woher soll Windows wissen, ab wann eine Sektion im PE-Format beginnt?
Aufbau einer PE-Datei
Zunächst einmal beginnt eine PE-Datei mit dem IMAGE_DOS_HEADER. Durch IMAGE_DOS_HEADER.e_lfanew gelanden wir zum nächsten Image dem IMAGE_NT_HEADERS. Und nach diesem IMAGE_NT_HEADERS folgt dann für jede Sektion ein IMAGE_SECTION_HEADER. Da die Größe der Header,also alles bis zur ersten Sektion, auch ein vielfaches sein muss, finden sich nach dem letzten IMAGE_SECTION_HEADER viele Nullbytes bevor die erste Sektion beginnt. Genau dort werden wir unseren neuen Sektionsheader einfügen und unsere Sektion ans Ende der Datei schreiben.
Der IMAGE_NT_HEADERS hat eine Signatur und zwei weitere Header, den IMAGE_OPTIONAL_HEADER und den IMAGE_FILE_HEADER. Beide Header liefern uns weitere Informationen über den Aufbau der ausführbaren Datei. Nach den ganzen Sektionen, inklusive der Nullbytes, finden wir die eigentlichen Sektionen, also die jeweiligen Daten der Sektionen.
Werfen wir nun einen Blick auf den IMAGE_NT_HEADERS, damit wir auch eine gültige Sektion zur Datei hinzufügen, wobei wir nur die Werte betrachten, die für unser Vorhaben wichtig sind.
IMAGE_NT_HEADERS
IMAGE_OPTIONAL_HEADER.ImageBase
Ist die Addresse an der unsere Datei von Windows geladen wird.
IMAGE_OPTIONAL_HEADER.SectionAlignment und IMAGE_OPTIONAL_HEADER.FileAlignment
Unsere Sektionsgröße muss ein vielfaches dieser beiden Werte sein, falls nicht müssen wir ein paar Nullbytes hinzufügen.
IMAGE_OPTIONAL_HEADER.SizeOfImage
Ist die Größe des Speichers den Windows alloziieren muss, damit die Datei in diesen geladen werden kann.
IMAGE_OPTIONAL_HEADER.SizeOfHeaders
Die Größe aller Header zusammen.
IMAGE_FILE_HEADER.NumberOfSections
Anzahl der Sektionen innerhalb der Datei.
IMAGE_SECTION_HEADER
Name
Der 8-Byte-Lange Name der Sektion (z.B. .text)
VirtualSize
Die Größe der Sektion, wenn diese in dem Speicher geladen wird.
VirtualAddress
Addresse an der unsere Sektion geladen wird. Hierbei ist die Addresse relativ zur ImageBase. Wenn wir eine ImageBase von 0x40000 haben und eine virtuelle Addresse von 0x01234 befindet sich unsere Sektion an der Addresse 0x41234.
SizeOfRawData
Größe der Sektion wenn wir sie von der Festplatte einlesen. Dieser Wert ist größer oder gleich der VirtualSize.
Durch das Alignment haben wir oft Nullbytes in der Sektion auf der Festplatte, die von Windows nicht beachtet werden, wenn die Datei in den Speicher geladen wird.
PointerToRawData
Offset zum ersten Byte der Sektions-Daten.
Characteristics
Legt unter anderem die Zugriffsrechte der Sektion fest (executable, readable, writeable…)
Eine Sektion hinzufügen
Da wir nun alle Werte kennen die wir fürs Hinzufügen benötigen, sollten wir uns den genauen Ablauf verdeutlichen.
Eine Überprüfung der Datei ist nicht unbedingt nötig, wäre aber für den allgemeinen gebrauch durchaus sinnvoll.
– Datei in ein Array einlesen
– e_lfanew auslesen um zu den NT_HEADERS zu kommen
– Überprüfen ob nach dem letzten Section-Header genügend Nullbytes vorhanden sind
– Sämtliche Section-Header durchgehen um die nächste virtuelle Addresse zu ermitteln.
– einen neuen Section-Header erstellen und diesen mit Werten füllen
– NumberOfSections erhöhen
– SizeOfImage erhöhen
– den neuen Section-Header nach dem letzten Section-Header einfügen
– Sektions-Daten ans Ende der Datei schreiben.
Das Auslesen der Sektion
Ich hoffe der ein oder andere wird an dieser Stelle laut aufschreien „ImageBase“ und genau das ist der Wert den wir auch brauchen. Diesen bekommen wir mithilfe der Funktion GetModuleHandleA(NULL). Anschließend müssen wir uns nur durch die Sektions-Struktur hangeln und dabei überprüfen, ob wir bei unserer Sektion angelangt sind.
Anschließend können wir mit ImageBase + IMAGE_SECTION_HEADER.VirtualAddress und IMAGE_SECTION_HEADER.VirtualSize die Sektion einlesen.
Der Code
Das ganze sieht nun wie folgt aus:
DWORD Align(DWORD Value, DWORD Alignment)
{
DWORD Out;
Out = ((Value % Alignment)== 0) ? Value : (Value + (Alignment - (Value % Alignment)));
return Out;
}
bool WriteDataToStubSection(const char* Data, size_t Size,const char* Name,std::string StubPath="", std::string OutPath="")
{
std::fstream Reader;
std::fstream Writer;
char* File;
size_t FileSize;
DWORD OffsetNewHeader;
IMAGE_DOS_HEADER* DosHeader;
IMAGE_NT_HEADERS32* NtHeaders;
IMAGE_SECTION_HEADER* SectionHeader;
IMAGE_SECTION_HEADER NewSection;
if(StubPath == "" | OutPath == "")
{
char Filepath[256];
std::string Path;
GetModuleFileNameA(0,Filepath,255);
Path = Filepath;
Path = Path.substr(0,Path.find_last_of('\')+1); //'
StubPath = Path + "Stub.exe";
OutPath = Path + "Output.exe";
}
memset((void*) &NewSection,0x00,sizeof(IMAGE_SECTION_HEADER));
// Datei vollstaendig per fstream einlesen und in File speichern
Reader.open(StubPath.c_str(),std::ios_base::binary|std::ios_base::in);
if(Reader.good())
{
Reader.seekg(0,std::ios_base::end);
FileSize = Reader.tellg();
File = new char[FileSize];
Reader.seekg(0,std::ios_base::beg);
Reader.read(File,FileSize);
Reader.close();
DosHeader = (IMAGE_DOS_HEADER*) File;
NtHeaders = (IMAGE_NT_HEADERS32*) &File[DosHeader->e_lfanew];
OffsetNewHeader = (DosHeader->e_lfanew + sizeof(IMAGE_NT_HEADERS32) + NtHeaders->FileHeader.NumberOfSections * sizeof(IMAGE_SECTION_HEADER));
// Ueberpruefe, ob nach dem letzten Header genug Platz fuer einen neuen ist
// NewSection wurde vorher komplett auf 0x00 gesetzt.
if(memcmp((void*) &File[OffsetNewHeader],(void*) &NewSection,sizeof(NewSection)) == 0)
{
//Genug Platz fuer neuen Section-Header
// Naechstgroesste Virtuelle Addresse berechnen
for(unsigned int i=0;i<NtHeaders->FileHeader.NumberOfSections;i++)
{
SectionHeader = (IMAGE_SECTION_HEADER*) &File[DosHeader->e_lfanew + sizeof(IMAGE_NT_HEADERS32) + i*sizeof(IMAGE_SECTION_HEADER)];
if(NewSection.VirtualAddress <= (SectionHeader->VirtualAddress + SectionHeader->Misc.VirtualSize))
{
NewSection.VirtualAddress = Align(SectionHeader->VirtualAddress + SectionHeader->Misc.VirtualSize,NtHeaders->OptionalHeader.SectionAlignment);
}
}
// Informationen ueber die Sektion dem neuen header uebermitteln
// Benutze hierfuer die Hilfsfunktion Align, welche die Werte zu einem naechst hoeheren Vielfachen einer Zahl rundet.
strncpy((char*) &NewSection.Name,Name,8);
NewSection.Misc.VirtualSize = Size;
NewSection.NumberOfLinenumbers = 0;
NewSection.NumberOfRelocations = 0;
NewSection.PointerToRelocations = 0;
NewSection.Characteristics = 0x40000000 |IMAGE_SCN_MEM_SHARED; // READABLE!
NewSection.PointerToRawData = FileSize;
NewSection.SizeOfRawData = Align(Size,NtHeaders->OptionalHeader.FileAlignment);
memcpy(&File[OffsetNewHeader],&NewSection,sizeof(IMAGE_SECTION_HEADER));
//Setze neue Informationen im Nt-Header
NtHeaders->FileHeader.NumberOfSections++;
NtHeaders->OptionalHeader.CheckSum = 0;
NtHeaders->OptionalHeader.SizeOfImage = Align(NewSection.VirtualAddress + NewSection.Misc.VirtualSize,NtHeaders->OptionalHeader.SectionAlignment);
//NtHeaders->OptionalHeader.SizeOfHeaders = ?
// Datei speichern und Sektion hinten anhaengen
// Wegen des Alignments noch ein paar 0x00 anhaengen!
Writer.open(OutPath.c_str(),std::ios_base::out|std::ios_base::binary);
if(Writer.good())
{
Writer.write(File,FileSize);
Writer.write(Data,Size);
for(unsigned long i=0;i<NewSection.SizeOfRawData - Size;i++)
{
Writer.put(0x00);
}
Writer.close();
Reader.close();
return true;
}
}
}
if(Writer.is_open())
Writer.close();
if(Reader.is_open())
Reader.close();
return false;
}
char* ReadDataFromStubSection(const char* Name,size_t& Size)
{
char* Data;
DWORD ImageBase;
IMAGE_DOS_HEADER* DosHeader;
IMAGE_NT_HEADERS32* NtHeaders;
IMAGE_SECTION_HEADER* SectionHeader;
ImageBase = (DWORD) GetModuleHandleA(0);
if(ImageBase != 0)
{
DosHeader = reinterpret_cast<IMAGE_DOS_HEADER*> (ImageBase);
NtHeaders = reinterpret_cast<IMAGE_NT_HEADERS32*> (ImageBase + DosHeader->e_lfanew);
SectionHeader = 0;
for(unsigned int i=0;i<NtHeaders->FileHeader.NumberOfSections;i++)
{
IMAGE_SECTION_HEADER* Section;
Section = reinterpret_cast<IMAGE_SECTION_HEADER*> (ImageBase + DosHeader->e_lfanew + sizeof(IMAGE_NT_HEADERS32) + i* sizeof(IMAGE_SECTION_HEADER));
if(strncmp(Name,(const char*) &Section->Name,8) == 0)
SectionHeader = Section;
}
if(SectionHeader != 0)
{
Size = SectionHeader->Misc.VirtualSize;
Data = new char[Size];
memcpy(Data,(void*) (ImageBase + SectionHeader->VirtualAddress),Size);
return Data;
}
else
MessageBoxA(0,"Sektion wurde nicht gefunden","Keine Sektion",0);
}
else
MessageBoxA(0,"Konnte ImageBase nicht berechnen","Ungueltiges Handle",0);
return 0;
}
Abschließende Worte
Dieser Artikel war schon etwas lang und ein wenig komplizierter. Ich hoffe es hat euch genauso viel Spaß gemacht diesen Artikel zu lesen, wie mir ihn zu schreiben. Und seid nett zum lieben Gehaxelt, er durfte das ganze hier Probelesen. Falls noch Fragen offen sind, besonders bezüglich des PE-Formats wie immer ein Kommentar verfassen oder mir eine Mail schreiben. Ich weiß längst nicht alles über das PE-Format, werde mir dennoch Mühe geben alle zu beantworten.
Da ich diverse Probleme mit dem Einbinden des Quelltextes hatte, stelle ich diese als Archiv zur Verfügung, um lästiges Kopieren zu vermeiden.
DatenAnAusfuehrbareDateiSRC
Und den ganzen Artikel gibt es natürlich auch als PDF.
mit freundlichen Grüßen
euer TgZero