LaVolpe
0
In diesem Tutorial erkläre ich euch wie man einen eigenen Blackboxdebugger unter Windows mit Python (2.7) programmiert.
Vorausgesetzt wird:
Nun, was ist ein sog. Blackboxdebugger ?
Ein Blackboxdebugger ( auch Disambler genannt ) disambliert ein Programm oder eine dynamische Link Libary (Windows: .dll ; Linux: .io ), d.h. er übersetzt den compilierten Code von den Nullen und Einsen zu einen schon besser lesbaren Code in der Programmiersprache Assembler.
Zum Programmieren des Debuggers werde ich die Grundfunktionen von Windows verwenden die in der kernel32.dll definiert sind.
1) Die Funktionen die Verwendet werden sollen:
Die Bedeutung der einzelnen Funktionen werde ich im Quellcode in den Kommentaren erklären.
Zunächst müssen wir die alle Strukturen die in den Funktionen definiert mit ctypes "nachbilden" damit sie von der jeweiligen Funktion verwendet werden können.
Legt zunächst eine Datei dbg_def.py an und schreibt den folgenden Code rein:
Das war der Code um die Funktionen zu deklarieren.
Hier kommt jetzt der genutzte Code, der die genutzten Algorithmen enthält:
Nun ich hoffen dieses Tutorial beschreibt genau den Text und die Schritte die gemacht werden müssen um einen Debugger zuprogrammieren.
Im Anhang findet ihr die fertigen Codes.
Bitte lasst ein Kommentar da, dieses Tut ist meine erste grössere Anleitung fürs Web.
http://www.hackerboard.de/images/smilies2/tongue.gif
http://www.hackerboard.de/images/smilies2/smilie.gif
LaVolpe
Vorausgesetzt wird:
- Windows (min. XP) (mit Linux/Unix geht es auch aber die Grundfunktionen unterscheiden sich
- Kenntnisse der Sprache Python
- Kenntnisse der Bedeutung von Breakpoints
- Kenntnisse der Sprache C/C++ ( nur zum verstehen der Win-Funktionen )
- Kenntnisse von ctypes
- Spaß am Programmieren
Nun, was ist ein sog. Blackboxdebugger ?
Ein Blackboxdebugger ( auch Disambler genannt ) disambliert ein Programm oder eine dynamische Link Libary (Windows: .dll ; Linux: .io ), d.h. er übersetzt den compilierten Code von den Nullen und Einsen zu einen schon besser lesbaren Code in der Programmiersprache Assembler.
Zum Programmieren des Debuggers werde ich die Grundfunktionen von Windows verwenden die in der kernel32.dll definiert sind.
1) Die Funktionen die Verwendet werden sollen:
- CreateProcessA()
- OpenProcess()
- DebugActiveProcess()
- WaitForDebugEvent()
- ContinueDebugEvent()
- DebugActiveProcessStop()
- OpenThread()
- CreateToolhelp32Snapshot()
- THREADENTRY32() || C structuer
- GetThreadContext()
- SetThreadContext()
- OpenThread()
- ReadProcessMemory()
- WriteProcessMemory()
- GetProcAddress()
- VirtualProtectEx()
Die Bedeutung der einzelnen Funktionen werde ich im Quellcode in den Kommentaren erklären.
Zunächst müssen wir die alle Strukturen die in den Funktionen definiert mit ctypes "nachbilden" damit sie von der jeweiligen Funktion verwendet werden können.
Legt zunächst eine Datei dbg_def.py an und schreibt den folgenden Code rein:
Code:
from ctypes import *
# Windows Typen werden zur Sicherheit definiert
# In den Kommentaren ist der C++ äquivalent
WORD = c_ushort # unsigned short
DWORD = c_ulong # unsigned long
LPBYTE = POINT(c_ubyte) # char
LPSTR = c_void_p # void
# Konstanten die während des Debuggen übergeben werden
DEBUG_PROCESS = 0x00000001
CREATE_NEW_CONSOLE = 0x00000010
# Hier sind die Debuggeventsdefiniert,
# sie werden teilweise in den Funktionen gebraucht.
EXCEPTION_DEBUG_EVENT = 0x1
CREATE_THREAD_DEBUG_EVENT = 0x2
CREATE_PROCESS_DEBUG_EVENT = 0x3
EXIT_THREAD_DEBUG_EVENT = 0x4
EXIT_PROCESS_DEBUG_EVENT = 0x5
LOAD_DLL_DEBUG_EVENT = 0x6
UNLOAD_DLL_DEBUG_EVENT = 0x7
OUTPUT_DEBUG_STRING_EVENT = 0x8
RIP_EVENT = 0x9
# Hier werden die einzelnen Werte gespeichert sollte man,
# bestimmte INT-werte setzen wollen um z.B: einen Softwarebreakpoint
# zu setzen
EXCEPTION_ACCESS_VIOLATION = 0xC0000005
EXCEPTION_BREAKPOINT = 0x80000003
EXCEPTION_GUARD_PAGE = 0x80000001
EXCEPTION_SINGLE_STEP = 0x80000004
# Hier sind Werte und Variabeln für einen Processsnapshot gesetzt
# Ein Processsnapshot wird mit der Funktion CreateToolhelp32Snapshot()
# erstellt.
# Processsnapshots sind einfach gesagt "Bilder" des Prozesses,
# mit denen kann man einen Prozess zu einen früheren Punkt
# "zurücksetzten". Die Variabeln sind auch betroffen.
TH32CS_SNAPHEAPLIST = 0x00000001
TH32CS_SNAPPROCESS = 0x00000002
TH32CS_SNAPTHREAD = 0x00000004
TH32CS_SNAPMODULE = 0x00000008
TH32CS_INHERIT = 0x80000000
TH32CS_SNAPALL = (TH32CS_SNAPHEAPLIST | TH32CS_SNAPPROCESS | TH32CS_SNAPTHREAD | TH32CS_SNAPMODULE)
THREAD_ALL_ACCESS = 0x001F03FF
# Diese Deklarationen werden von der Funktion GetThreadContext()
# verwendet, um die einzelnen CPU-Register mit den Werten abzurufen
CONTEXT_FULL = 0x00010007
CONTEXT_DEBUG_REGISTERS = 0x00010010
# Hier werden die Rechte definiert mit denen man auf die Werte zugreift.
# Dieses Thema ist etwas kompliziert und hier nicht von belangen deswegen
# werde ich , dies nicht weiter erklären.
# Es reicht wenn ihr es reinschreibt
PAGE_EXECUTE_READWRITE = 0x00000040
# Hier sind die "Bedingungen" definiert um einen Hardwarebreakpoint zusetzten
HW_ACCESS = 0x00000003
HW_EXECUTE = 0x00000000
HW_WRITE = 0x00000001
# Hier sind die Rechte zum Zugreifen auf die Dateien definiert.
# Diese werden von der Funktion VitrualProtect() verwendet.
#
PAGE_NOACCESS = 0x00000001
PAGE_READONLY = 0x00000002
PAGE_READWRITE = 0x00000004
PAGE_WRITECOPY = 0x00000008
PAGE_EXECUTE = 0x00000010
PAGE_EXECUTE_READ = 0x00000020
PAGE_EXECUTE_READWRITE = 0x00000040
PAGE_EXECUTE_WRITECOPY = 0x00000080
PAGE_GUARD = 0x00000100
PAGE_NOCACHE = 0x00000200
PAGE_WRITECOMBINE = 0x00000400
# Für die Funktion CreateProcessA(), damit werden die Programme gestartet,
class STARTUPINFO(Structure):
_fields_ = [
("cb", DWORD),
("lpReserved", LPTSTR),
("lpDesktop", LPTSTR),
("lpTitle", LPTSTR),
("dwX", DWORD),
("dwY", DWORD),
("dwXSize", DWORD),
("dwYSize", DWORD),
("dwXCountChars", DWORD),
("dwYCountChars", DWORD),
("dwFillAttribute",DWORD),
("dwFlags", DWORD),
("wShowWindow", WORD),
("cbReserved2", WORD),
("lpReserved2", LPBYTE),
("hStdInput", HANDLE),
("hStdOutput", HANDLE),
("hStdError", HANDLE),
]
# Hier werden einzelne Informationen für die Funktionen "abgefangen"
class PROCESS_INFORMATION(Structure):
_fields_ = [
("hProcess", HANDLE),
("hThread", HANDLE),
("dwProcessId", DWORD),
("dwThreadId", DWORD),
]
class EXCEPTION_RECORD(Structure):
pass
EXCEPTION_RECORD._fields_ = [
("ExceptionCode", DWORD),
("ExceptionFlags", DWORD),
("ExceptionRecord", POINTER(EXCEPTION_RECORD)),
("ExceptionAddress", PVOID),
("NumberParameters", DWORD),
("ExceptionInformation", UINT_PTR * 15),
]
# Code für dwDebugEvent
# Ich gehe hierauf nicht weiter ein
class _EXCEPTION_RECORD(Structure):
_fields_ = [
("ExceptionCode", DWORD),
("ExceptionFlags", DWORD),
("ExceptionRecord", POINTER(EXCEPTION_RECORD)),
("ExceptionAddress", PVOID),
("NumberParameters", DWORD),
("ExceptionInformation", UINT_PTR * 15),
]
# Exceptionen werden hier abgefangen
class EXCEPTION_DEBUG_INFO(Structure):
_fields_ = [
("ExceptionRecord", EXCEPTION_RECORD),
("dwFirstChance", DWORD),
]
# Weitere Klasse des Debugevents
#
class DEBUG_EVENT_UNION(Union):
_fields_ = [
("Exception", EXCEPTION_DEBUG_INFO),
("CreateThread", CREATE_THREAD_DEBUG_INFO),
("CreateProcessInfo", CREATE_PROCESS_DEBUG_INFO),
("ExitThread", EXIT_THREAD_DEBUG_INFO),
("ExitProcess", EXIT_PROCESS_DEBUG_INFO),
("LoadDll", LOAD_DLL_DEBUG_INFO),
("UnloadDll", UNLOAD_DLL_DEBUG_INFO),
("DebugString", OUTPUT_DEBUG_STRING_INFO),
("RipInfo", RIP_INFO),
]
# Debugevent ....
class DEBUG_EVENT(Structure):
_fields_ = [
("dwDebugEventCode", DWORD),
("dwProcessId", DWORD),
("dwThreadId", DWORD),
("u", DEBUG_EVENT_UNION),
]
# Wird für den Programm Kontext beim debuggen verwendet
class FLOATING_SAVE_AREA(Structure):
_fields_ = [
("ControlWord", DWORD),
("StatusWord", DWORD),
("TagWord", DWORD),
("ErrorOffset", DWORD),
("ErrorSelector", DWORD),
("DataOffset", DWORD),
("DataSelector", DWORD),
("RegisterArea", BYTE * 80),
("Cr0NpxState", DWORD),
]
# Klasse für die einzelnen CPU-Register.
# Die Klasse wird von GetThreadContext() genutzt.
class CONTEXT(Structure):
_fields_ = [
("ContextFlags", DWORD),
("Dr0", DWORD),
("Dr1", DWORD),
("Dr2", DWORD),
("Dr3", DWORD),
("Dr6", DWORD),
("Dr7", DWORD),
("FloatSave", FLOATING_SAVE_AREA),
("SegGs", DWORD),
("SegFs", DWORD),
("SegEs", DWORD),
("SegDs", DWORD),
("Edi", DWORD),
("Esi", DWORD),
("Ebx", DWORD),
("Edx", DWORD),
("Ecx", DWORD),
("Eax", DWORD),
("Ebp", DWORD),
("Eip", DWORD),
("SegCs", DWORD),
("EFlags", DWORD),
("Esp", DWORD),
("SegSs", DWORD),
("ExtendedRegisters", BYTE * 512),
]
# Hier werden einige Informationen der jeweiligen Threads gespeichert
class THREADENTRY32(Structure):
_fields_ = [
("dwSize", DWORD),
("cntUsage", DWORD),
("th32ThreadID", DWORD),
("th32OwnerProcessID", DWORD),
("tpBasePri", DWORD),
("tpDeltaPri", DWORD),
("dwFlags", DWORD),
]
# Variabeln für die Systemstruktur -> SYSTEM_INFO
class PROC_STRUCT(Structure):
_fields_ = [
("wProcessorArchitecture", WORD),
("wReserved", WORD),
]
# Variabeln für die Systemstruktur -> SYSTEM_INFO
class SYSTEM_INFO_UNION(Union):
_fields_ = [
("dwOemId", DWORD),
("sProcStruc", PROC_STRUCT),
]
# Die Klasse wird gebraucht sollte ein Program die Aufbaustruktur des PC/ CPU
# abfragen
class SYSTEM_INFO(Structure):
_fields_ = [
("uSysInfo", SYSTEM_INFO_UNION),
("dwPageSize", DWORD),
("lpMinimumApplicationAddress", LPVOID),
("lpMaximumApplicationAddress", LPVOID),
("dwActiveProcessorMask", DWORD),
("dwNumberOfProcessors", DWORD),
("dwProcessorType", DWORD),
("dwAllocationGranularity", DWORD),
("wProcessorLevel", WORD),
("wProcessorRevision", WORD),
]
# MEMORY_BASIC_INFORMATION enthält einige Information von bestimmten
# Speicherbereichen
class MEMORY_BASIC_INFORMATION(Structure):
_fields_ = [
("BaseAddress", PVOID),
("AllocationBase", PVOID),
("AllocationProtect", DWORD),
("RegionSize", SIZE_T),
("State", DWORD),
("Protect", DWORD),
("Type", DWORD),
]
Das war der Code um die Funktionen zu deklarieren.
Hier kommt jetzt der genutzte Code, der die genutzten Algorithmen enthält:
Code:
from ctypes import *
from my_debugger_defines import *
import sys
import time
kernel32 = windll.kernel32
class debugger():
def __init__(self):
self.h_process = None
self.pid = None
self.debugger_active = False
self.h_thread = None
self.context = None
self.breakpoints = {}
self.first_breakpoint= True
self.hardware_breakpoints = {}
# Hier wird die jeweiligen "Seitengrösse" des System abgefragt
system_info = SYSTEM_INFO()
kernel32.GetSystemInfo(byref(system_info))
self.page_size = system_info.dwPageSize
self.guarded_pages = []
self.memory_breakpoints = {}
def load(self,path_to_exe):
# Ohne diesen Code würde die GUI des debuggden Programms nicht
# angezeigt werden
creation_flags = DEBUG_PROCESS
# Programm Variabeln
startupinfo = STARTUPINFO()
process_information = PROCESS_INFORMATION()
# Die jeweiligen Werte können verwendet werden
# so kann man die Unterschiede des Startup bei verschiedenen
# Variabel einträgen sehen
startupinfo.dwFlags = 0x1
startupinfo.wShowWindow = 0x0
# Die CB Klasse wird hiermit Initialisiert
startupinfo.cb = sizeof(startupinfo)
# CreateProcessA zum Starten von Programmen
if kernel32.CreateProcessA(path_to_exe,
None,
None,
None,
None,
creation_flags,
None,
None,
byref(startupinfo),
byref(process_information)):
print "[*] We have successfully launched the process!"
print "[*] The Process ID I have is: %d" % \
process_information.dwProcessId
self.pid = process_information.dwProcessId
self.h_process = self.open_process(self,process_information.dwProcessId)
self.debugger_active = True
else:
print "[*] Error with error code %d." % kernel32.GetLastError()
def open_process(self,pid):
# Kommentarzeichen je nach belieben entfernen :-)
# PROCESS_ALL_ACCESS = 0x0x001F0FFF
h_process = kernel32.OpenProcess(PROCESS_ALL_ACCESS,False,pid)
return h_process
def attach(self,pid):
self.h_process = self.open_process(pid)
if kernel32.DebugActiveProcess(pid):
self.debugger_active = True
self.pid = int(pid)
else:
print "[*] Unable to attach to the process."
def run(self):
# Debugging Events werden hier verwendet
while self.debugger_active == True:
self.get_debug_event()
def get_debug_event(self):
debug_event = DEBUG_EVENT()
continue_status = DBG_CONTINUE
if kernel32.WaitForDebugEvent(byref(debug_event),100):
# Holt bestimmte benutzte Information her
self.h_thread = self.open_thread(debug_event.dwThreadId)
self.context = self.get_thread_context(h_thread=self.h_thread)
self.debug_event = debug_event
print "Event Code: %d Thread ID: %d" % \
(debug_event.dwDebugEventCode,debug_event.dwThreadId)
if debug_event.dwDebugEventCode == EXCEPTION_DEBUG_EVENT:
self.exception = debug_event.u.Exception.ExceptionRecord.ExceptionCode
self.exception_address = debug_event.u.Exception.ExceptionRecord.ExceptionAddress
# Wird benutzt um bestimmt Exceptionen aufzugreifen
if self.exception == EXCEPTION_ACCESS_VIOLATION:
print "Access Violation Detected."
elif self.exception == EXCEPTION_BREAKPOINT:
continue_status = self.exception_handler_breakpoint()
elif self.exception == EXCEPTION_GUARD_PAGE:
print "Guard Page Access Detected."
elif self.exception == EXCEPTION_SINGLE_STEP:
self.exception_handler_single_step()
kernel32.ContinueDebugEvent(debug_event.dwProcessId, debug_event.dwThreadId, continue_status)
# Code erklärt sich hier von selbst
# Beim Beenden wird detach verwendet
def detach(self):
if kernel32.DebugActiveProcessStop(self.pid):
print "[*] Finished debugging. Exiting..."
return True
else:
print "There was an error"
return False
# Öffnen eines Threads
def open_thread (self, thread_id):
h_thread = kernel32.OpenThread(THREAD_ALL_ACCESS, None, thread_id)
if h_thread is not None:
return h_thread
else:
print "[*] Could not obtain a valid thread handle."
return False
def enumerate_threads(self):
thread_entry = THREADENTRY32()
thread_list = []
snapshot = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, self.pid)
if snapshot is not None:
# Grösse muss hier (im Programmablauf) definiert werden
thread_entry.dwSize = sizeof(thread_entry)
success = kernel32.Thread32First(snapshot, byref(thread_entry))
while success:
if thread_entry.th32OwnerProcessID == self.pid:
thread_list.append(thread_entry.th32ThreadID)
success = kernel32.Thread32Next(snapshot, byref(thread_entry))
# No need to explain this call, it closes handles
# so that we don't leak them.
kernel32.CloseHandle(snapshot)
return thread_list
else:
return False
def get_thread_context (self, thread_id=None,h_thread=None):
context = CONTEXT()
context.ContextFlags = CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS
# Enthält den Programhandler
if h_thread is None:
self.h_thread = self.open_thread(thread_id)
if kernel32.GetThreadContext(self.h_thread, byref(context)):
return context
else:
return False
def read_process_memory(self,address,length):
data = ""
read_buf = create_string_buffer(length)
count = c_ulong(0)
kernel32.ReadProcessMemory(self.h_process, address, read_buf, 5, byref(count))
data = read_buf.raw
return data
def write_process_memory(self,address,data):
count = c_ulong(0)
length = len(data)
c_data = c_char_p(data[count.value:])
if not kernel32.WriteProcessMemory(self.h_process, address, c_data, length, byref(count)):
return False
else:
return True
def bp_set(self,address):
print "[*] Setting breakpoint at: 0x%08x" % address
if not self.breakpoints.has_key(address):
# Original Wert wird behalten
old_protect = c_ulong(0)
kernel32.VirtualProtectEx(self.h_process, address, 1, PAGE_EXECUTE_READWRITE, byref(old_protect))
original_byte = self.read_process_memory(address, 1)
if original_byte != False:
# Interupt Code wird hier behalten
if self.write_process_memory(address, "\xCC"):
# Breakpoint wird wird aufgenommen
self.breakpoints[address] = (original_byte)
return True
else:
return False
def exception_handler_breakpoint(self):
print "[*] Exception address: 0x%08x" % self.exception_address
# Zeigt ob das UNSER Breakpoint ist
if not self.breakpoints.has_key(self.exception_address):
if self.first_breakpoint == True:
self.first_breakpoint = False
print "[*] Hit the first breakpoint."
return DBG_CONTINUE
else:
print "[*] Hit user defined breakpoint."
# Handle für den breakpoint
self.write_process_memory(self.exception_address, self.breakpoints[self.exception_address])
self.context = self.get_thread_context(h_thread=self.h_thread)
self.context.Eip -= 1
kernel32.SetThreadContext(self.h_thread,byref(self.context))
continue_status = DBG_CONTINUE
return continue_status
def func_resolve(self,dll,function):
handle = kernel32.GetModuleHandleA(dll)
address = kernel32.GetProcAddress(handle, function)
kernel32.CloseHandle(handle)
return address
def bp_set_hw(self, address, length, condition):
# Überprüft ob ein falscher Wert da ist
if length not in (1, 2, 4):
return False
else:
length -= 1
# Checkt auf Falsche Bedingungen
if condition not in (HW_ACCESS, HW_EXECUTE, HW_WRITE):
return False
if not self.hardware_breakpoints.has_key(0):
available = 0
elif not self.hardware_breakpoints.has_key(1):
available = 1
elif not self.hardware_breakpoints.has_key(2):
available = 2
elif not self.hardware_breakpoints.has_key(3):
available = 3
else:
return False
# Register werden beibehalten
for thread_id in self.enumerate_threads():
context = self.get_thread_context(thread_id=thread_id)
# Breakpoint wird aufgenommen
context.Dr7 |= 1 << (available * 2)
# Adresse des Breakpoints wird gespeichert
if available == 0: context.Dr0 = address
elif available == 1: context.Dr1 = address
elif available == 2: context.Dr2 = address
elif available == 3: context.Dr3 = address
# Breakpoint Bedingung wird hier gespeichert
context.Dr7 |= condition << ((available * 4) + 16)
# Wert/Länge wird gesetzt
context.Dr7 |= length << ((available * 4) + 18)
h_thread = self.open_thread(thread_id)
kernel32.SetThreadContext(h_thread,byref(context))
# Hardwarebreakpoint wird hier gesetzt
self.hardware_breakpoints[available] = (address,length,condition)
return True
###################
def exception_handler_single_step(self):
print "[*] Exception address: 0x%08x" % self.exception_address
if self.context.Dr6 & 0x1 and self.hardware_breakpoints.has_key(0):
slot = 0
elif self.context.Dr6 & 0x2 and self.hardware_breakpoints.has_key(1):
slot = 0
elif self.context.Dr6 & 0x4 and self.hardware_breakpoints.has_key(2):
slot = 0
elif self.context.Dr6 & 0x8 and self.hardware_breakpoints.has_key(3):
slot = 0
else:
# Kein INT1!
continue_status = DBG_EXCEPTION_NOT_HANDLED
# Breakpoint wird gelöscht
if self.bp_del_hw(slot):
continue_status = DBG_CONTINUE
print "[*] Hardware breakpoint removed."
return continue_status
def bp_del_hw(self,slot):
# Alle Breakpoints werden deaktiviert
for thread_id in self.enumerate_threads():
context = self.get_thread_context(thread_id=thread_id)
context.Dr7 &= ~(1 << (slot * 2))
# Adressen werden auf 0 gesetzt
if slot == 0:
context.Dr0 = 0x00000000
elif slot == 1:
context.Dr1 = 0x00000000
elif slot == 2:
context.Dr2 = 0x00000000
elif slot == 3:
context.Dr3 = 0x00000000
# Bedingungen werden gelöscht
context.Dr7 &= ~(3 << ((slot * 4) + 16))
context.Dr7 &= ~(3 << ((slot * 4) + 18))
# Context wird "reseted"
h_thread = self.open_thread(thread_id)
kernel32.SetThreadContext(h_thread,byref(context))
# Breakpoint wird von der Listegelöscht
del self.hardware_breakpoints[slot]
return True
def bp_set_mem (self, address, size):
mbi = MEMORY_BASIC_INFORMATION()
# Adresse der Speicherseite
if kernel32.VirtualQueryEx(self.h_process, address, byref(mbi), sizeof(mbi)) < sizeof(mbi):
return False
current_page = mbi.BaseAddress
# Rechte für die Breakpoints
while current_page <= address + size:
# Seiteninformation wird ans OS geschickt
self.guarded_pages.append(current_page)
old_protection = c_ulong(0)
if not kernel32.VirtualProtectEx(self.h_process, current_page, size, mbi.Protect | PAGE_GUARD, byref(old_protection)):
return False
# Verkleinert die Grösse des Speichers
current_page += self.page_size
# Speicher Breakpoints werden gelöscht
self.memory_breakpoints[address] = (address, size, mbi)
return True
Nun ich hoffen dieses Tutorial beschreibt genau den Text und die Schritte die gemacht werden müssen um einen Debugger zuprogrammieren.
Im Anhang findet ihr die fertigen Codes.
Bitte lasst ein Kommentar da, dieses Tut ist meine erste grössere Anleitung fürs Web.
http://www.hackerboard.de/images/smilies2/tongue.gif
http://www.hackerboard.de/images/smilies2/smilie.gif
LaVolpe