Click here to Skip to main content
Click here to Skip to main content
Go to top

NHook - A .NET debugger API for x86

, 1 Dec 2012
Rate this:
Please Sign up or sign in to vote.
Debugger API, explore and modify running program easily
using(var dbg = new ProcessDebugger())
{
    dbg.Start(SimpleCrackMe);
    RVA mainRVA = dbg.SymbolManager.FromName("SimpleCrackMe.exe", "main").RVA; //Get main address
    Breakpoint breakPoint = dbg.Breakpoints.At("SimpleCrackMe.exe", mainRVA); //Set breakpoint
    dbg.BreakingThread.Continue.UntilBreakpoint(breakPoint); //Run to it
    var disasm = dbg.BreakingThread.Continue.UntilInstruction("JNZ"); //Continue execution until next JNZ
    dbg.BreakingThread.WriteInstruction("NOP"); //Nop the JNZ
    dbg.Patches.ModifyImage(dbg.Debuggee.MainModule, "SimpleCrackMe-Patched.exe"); //Save changes back to a file
    Assert.Equal(1, dbg.Continue.ToEnd()); //Debugged process resolved !
    var result = StartAndGetReturnCode("SimpleCrackMe-Patched.exe");
    Assert.Equal(1, result); //SimpleCrakMe-Patched also resolved !!!!
}

Why would you need a debugger API ?

I am not the kind of person that can break a new device into piece to know how it is made. I am not an hardware person. Every person that once gave me a screwdriver regretted it afterward.

For software, that’s a different story. When a software do cool stuff, I want to know how they did that… I also use exactly the same toolset to troubleshot bugs, because software almost never work as expected… Google and MSDN are sometimes not enough to solve your problem.

So I use lots of different tool to see what is going on inside, these programs are like your eyes inside windows, and you certainly use them on a day by day basis… For example, the mighty Procmon to troubleshoot file system and registry access, the great  API Monitor to troubleshot problematic API calls –This one should deserve more attention, it’s a truly great tool-, the famous Wireshark for network or protocol related problems, and, in the last resort debuggers like OllyDbg, IDA pro, and WinDbg.

The problem is that I love my comfortable .NET world, so I thought “Hey, what if all of these great tool were available within a nice and friendly .NET API ?” so the this API is my response to this question. Not everything is implemented yet, but that’s the first bits. For now it only support x86 programs.

At the end of this article, you will be able to :

  • Set breakpoints programmatically in assembler
  • Find the address of modules and functions imported and loaded in a remote process.
  • By extension, setting breakpoint in well known WIN API functions.
  • Apply patches to remote process (changing x86 instructions or data)
  • Push the patches back to file.

All of that nicely implemented in comfortable .NET API.

For advanced developers you will understand :

  • How a debugger works (Under the hood)
  • What is a native breakpoint (Under the hood)
  • What is a PE file, a module, a PDB.

NHook is using OllyDbg assembler/disassembler for the assembler part.

NHook and this article as been bought to you by Dusan Trtica and me. We are working together on this project. Dusan is a very talented developer in C++, this project advances smoothly thanks to him ! (I’m wayyyy toooo slooooow in C++ !)

So let’s get started !

How to crack a simple Crack Me program

Before coding anything, NHook's goal is to make cracking this simple program easy. Now that the goal is set, coding is just a matter of expressing the path to this goal.

#include <stdio.h>

int main(int argc, char** argv)
{
    if(argc == 3)
    {
        printf("success");
        return 1;
    }
    printf("miss");
    return 0;
}

With NHook, I will bypass the if condition so that even if I invoke the program without 2 parameters, it will print success.

What does the assembly code of this function look like ?

To see the assembly code let’s run SimpleCrackMe.exe with OllyDbg, and let’s bring up the Executable modules window.

image

The code is compiled with the VC++ 2012 runtime, so please download and install it, or the loader will not find MSVCR110D (Debug library of the C Runtime library that contains printf).

You can see that before running any code, several .dll have been loaded into my process address space. How did the loader know which dll to load ? Well… that’s just written in the import table of any PE file (dll or exe file). You can view it with CFF explorer.

image

Each imported dll have other dependencies that will be loaded as well. OllyDbg blocks only when the loader is done.

So in the Executable modules window you can see the SimpleCrackMe is loaded at 0x3D0000 and the entrypoint is at 0x3E110E.

So you can think that 0x3E110E is the address of main… but wrong ! It might be that if you disable every setting and feature of ms compiler, but there are some bootstrapping code. If that is a mixed assembly (C++/CLI), you have some .NET related stuff, and if that’s time linked with C runtime libraries, there are some bootstrapping also... Here is the entry point… A just to the bootstrap mainCRTStartup.

image

You can also get the entry point of your executable by using CFF Explorer, the entry point is in the PE file.

image

0x1110E is the address relative to the base address of module SimpleCrackMe. It is called an RVA (relative virtual address).

That’s why the effective VA (virtual address) shown by Ollydbg is 0x3D0000 + 0x1110E = 0x3E110E.

So, back to the question : how could you find the address of main ?  Response : by using pdb.

PDB is just a database of symbols (a name and a type) with their RVA and maybe source file and line.

In .NET, PDB does not store RVA, but only MetadataToken-Source file:line. The JIT compiler is ultimately responsible to choose where to transform MSIL and how to decompile it (x86,64 etc…), that’s why the C# or VB .NET compiler can’t predict RVA in advance and use MetadataToken instead. I will probably extend NHook to support .NET, so this will be a subject for the next article.

Let’s right click on the SimpleCrackMe.exe  module in the  Executable View, and it will show us all symbols it resolved thanks to pdb or PE’s export directory.

Search for the main symbol, click on it and you should see the code in the memory dump.

image

If you see an hex editor instead of the instructions, right click on the memory dump, and click on disassemble, to change it’s view.

The interesting stuff is that : There is a test at 0x3E13EE and a conditional JMP (JNZ) on it just after at 0x3E13F2… This is the if we need to bypass. 

We can bypass it by patching the JNZ with two NOP instructions.

Don’t worry… NHook link with OllyDbg’s assembler/disassembler library, so you don’t have to know op codes for these instructions.

Now I exposed the basic, you can understand all you have to know to use NHook, the following code should be self explanatory.

using(var dbg = new ProcessDebugger())
{
    dbg.Start(SimpleCrackMe);
    RVA mainRVA = dbg.SymbolManager.FromName("SimpleCrackMe.exe", "main").RVA; //Get main address
    Breakpoint breakPoint = dbg.Breakpoints.At("SimpleCrackMe.exe", mainRVA); //Set breakpoint
    dbg.BreakingThread.Continue.UntilBreakpoint(breakPoint); //Run to it
    var disasm = dbg.BreakingThread.Continue.UntilInstruction("JNZ"); //Continue execution until next JNZ
    dbg.BreakingThread.WriteInstruction("NOP"); //Nop the JNZ
    dbg.Patches.ModifyImage(dbg.Debuggee.MainModule, "SimpleCrackMe-Patched.exe"); //Save changes back to a file
    Assert.Equal(1, dbg.Continue.ToEnd()); //Debugged process resolved !
    var result = StartAndGetReturnCode("SimpleCrackMe-Patched.exe");
    Assert.Equal(1, result); //SimpleCrakMe-Patched also resolved !!!!
}

Let’s dig deeper

If you just want to use NHook you can stop here, I will not expose new features in this section, if you want to understand how things work under the hood… you can continue.

The first question I will respond is this one : What is a breakpoint ?

A breakpoint is simply an x86 instruction (INT 3) that will fire an interrupt that is handled by a debugger. You can verify it by yourself, try to set a breakpoint with Ollydbg.

image

Then, check the instruction’s address 0x012E13D0 with another memory editor. For example, with the excellent API monitor.

image

So the debugger is reporting value, 0x55 (PUSH EBP), but the memory editor is reporting 0xCC (INT 3). Conclusion : A debugger put breakpoints by overwriting a given instruction by 0xCC (INT 3).

So your next question might be : How to write in another’s process memory ?

You can do that with WriteProcessMemory. Here is a simple C++/CLI wrapper, the parameter processHandle can easily be found with the Process.Handle method in .NET.

public:static void WriteMemory(IntPtr processHandle, IntPtr baseAddress, array<Byte>^ input)
       {
           mauto_handle<BYTE> bytes(Util::ToCArray(input));
           SIZE_T readen;
           ASSERT_TRUE(WriteProcessMemory(processHandle.ToPointer(),baseAddress.ToPointer(),bytes.get(),input->Length,&readen));
       }

WriteProcessMemory and all debug method will not work if the debugger’s thread security token does not have SeDebugPrivilege. Moreover this privilege is filtered when you are using UAC.

Here is a screenshot of procexp, that show a process (csrss.exe) running with the System account. System’s account token has the SeDebugPrivilege.

image

You can grant this privilege with Local Computer Policy (enabled by default for Administrators).

image

If you don’t have these rights, the debuggee should grant to debugger’s process ACL should grant PROCESS_VM_OPERATION PROCESS_VM_READ PROCESS_VM_WRITE rights… But we will not take that path.

Now that you understand what a breakpoint is, how do you attach a debugger to a process ?

The first solution, is to start the process with the debugger. Here is a C++/CLI wrapper.

static ProcessInformation^ StartDebugProcess(String^ appPath)
{
    marshal_context context;
    STARTUPINFO startupInfo = {0};
    startupInfo.cb = sizeof(STARTUPINFO);

    PROCESS_INFORMATION processInformation = {0};
    auto directory = System::IO::Path::GetDirectoryName(appPath);
    ASSERT_TRUE(CreateProcess(context.marshal_as<LPCTSTR>(appPath),
        NULL, 
        NULL,
        NULL,
        false,
        CREATE_DEFAULT_ERROR_MODE | CREATE_NEW_CONSOLE | DEBUG_ONLY_THIS_PROCESS | NORMAL_PRIORITY_CLASS,
        NULL,
        context.marshal_as<LPCTSTR>(directory),
        &startupInfo,
        &processInformation));
    return gcnew ProcessInformation(&processInformation);
}

The interesting point is the creation flag DEBUG_ONLY_THIS_PROCESS. Contrary to  DEBUG_PROCESS it will not debug child processes.

The second solution is to attach to a running process with DebugActiveProcess.

public: static ProcessInformation^ DebugActiveProcess(int pid)
        {
            ASSERT_TRUE(::DebugActiveProcess(pid));
            auto process = System::Diagnostics::Process::GetProcessById(pid);
            PROCESS_INFORMATION info;
            info.dwProcessId = process->Id;
            info.dwThreadId = process->Threads[0]->Id;
            info.hProcess = OpenProcess(PROCESS_ALL_ACCESS,false,process->Id);
            info.hThread = OpenThread(THREAD_ALL_ACCESS,false, process->Threads[0]->Id);
            return gcnew ProcessInformation(&info);
        }

Once your debugger is attached, only 2 functions will control how to receive debuggee’s events and when to continue execution.

The first is WaitForDebugEvent. It will break debugger’s thread until the next debug event. Once a debug event is received, you call ContinueDebugEvent to continue debuggee’s execution.

My C++/CLI wrapper transform the Debug_Event into CLR types. Current interesting debug events are : Dll Loaded, Exception thrown, CreateThread, and ExitProcess.

static DebugEvent^ WaitForEvent(ProcessInformation^ processInformation, TimeSpan timeout)
{
    DEBUG_EVENT dbgEvent;
    BOOL result;
    if(timeout == TimeSpan::MaxValue)
        result = WaitForDebugEvent(&dbgEvent, INFINITE);
    else
        result = WaitForDebugEvent(&dbgEvent, (DWORD)timeout.TotalMilliseconds);
    if(!result)
        return nullptr;

    if(dbgEvent.dwDebugEventCode == (DWORD)DebugEventType::LoadDllEvent)
        return gcnew DebugEventEx<LoadDllDetail^>(&dbgEvent, gcnew LoadDllDetail(processInformation, &dbgEvent.u.LoadDll));
    if(dbgEvent.dwDebugEventCode == (DWORD)DebugEventType::ExceptionEvent)
        return gcnew DebugEventEx<ExceptionDetail^>(&dbgEvent, gcnew ExceptionDetail(processInformation, &dbgEvent.u.Exception));
    if(dbgEvent.dwDebugEventCode == (DWORD)DebugEventType::CreateThreadEvent)
        return gcnew DebugEventEx<CreateThreadDetail^>(&dbgEvent, gcnew CreateThreadDetail(processInformation, &dbgEvent.u.CreateThread));
    if(dbgEvent.dwDebugEventCode == (DWORD)DebugEventType::ExitProcessEvent)
        return gcnew DebugEventEx<ExitProcessDetail^>(&dbgEvent, gcnew ExitProcessDetail(processInformation, &dbgEvent.u.ExitProcess));
    return gcnew DebugEvent(&dbgEvent);
}

ContinueDebugEvent is just asking the process and thread which need to continue, as well as, if the debugger handle an exception.

static void Continue(DebugEvent^ debugEvent, bool handleException)
{
    ContinueDebugEvent(debugEvent->ProcessId, debugEvent->ThreadId, handleException ? DBG_CONTINUE : DBG_EXCEPTION_NOT_HANDLED);
}

So now, how do you break into a breakpoint ?

Simple enough : Loop through all DebugEvent and stop when an “exception” one is receive, and that the reason is “Breakpoint”, here is the C# code, that do just that.

public DebugEventEx<ExceptionDetail> UntilNextBreakpoint()
{
    return (DebugEventEx<ExceptionDetail>)Until(ev =>
    {
        if(ev.EventType != DebugEventType.ExceptionEvent)
            return false;
        return ((DebugEventEx<ExceptionDetail>)ev).Details.Exception.Reason == ExceptionReason.ExceptionBreakpoint;
    });
}

The Until method loop until the predicate returns true.(The Run method ultimately call ContinueDebugEvent and the NextEvent method ultimately call the WaitDebugEvent method)

public DebugEvent Until(Func<DebugEvent, bool> filter)
{
    DebugEvent lastEvent = null;
    bool first = true;
    if(_Commandable.CurrentEvent != null)
        Run();
    while(lastEvent == null || !filter(lastEvent))
    {
        if(!first)
            Run();
        first = false;
        lastEvent = _Commandable.Wait.NextEvent();
    }
    return lastEvent;
}

How to dump memory addresses from PDB

Without PDBs, life would be hard for developers. Without PDB you would have no way to know that your current thread broke inside the main method. The only thing you would see is a bunch of bytes on the stack.

And so you would have no way to know the value or type of local variables during your debugging session. Life would be hard. So hard than debugging expert would tell you that PDB are as important as your source code.

That’s precisely thanks to PDBs I was able to find the RVA of the main method in this example.

using(var dbg = new ProcessDebugger())
{
    dbg.Start(SimpleCrackMe);
    RVA mainRVA = dbg.SymbolManager.FromName("SimpleCrackMe.exe", "main").RVA; //Get main address
    Breakpoint breakPoint = dbg.Breakpoints.At("SimpleCrackMe.exe", mainRVA); //Set breakpoint
    dbg.BreakingThread.Continue.UntilBreakpoint(breakPoint); //Run to it
    var disasm = dbg.BreakingThread.Continue.UntilInstruction("JNZ"); //Continue execution until next JNZ
    dbg.BreakingThread.WriteInstruction("NOP"); //Nop the JNZ
    dbg.Patches.ModifyImage(dbg.Debuggee.MainModule, "SimpleCrackMe-Patched.exe"); //Save changes back to a file
    Assert.Equal(1, dbg.Continue.ToEnd()); //Debugged process resolved !
    var result = StartAndGetReturnCode("SimpleCrackMe-Patched.exe");
    Assert.Equal(1, result); //SimpleCrakMe-Patched also resolved !!!!
}

Sounds cool ? So let’s see how to parse a PDB.

Microsoft ship with visual studio a COM component called DIA (Debug Interface Access) that does just that. So I just had to generate the COM interop .NET assembly for this COM component.

Go to C:\ProgF\Microsoft Visual Studio 11.0\DIA SDK\idl.

Generate a tlb file from the idl.
midl /I "C:\ProgF\Microsoft Visual Studio 11.0\DIA SDK\include" dia2.idl /tlb dia2.tlb

Generate the dll from the tlb
tlbimp dia2.tlb

Reference the resulting Dia2Lib.dll in NHook.

The rest is only about creating the one DiaSource object for each module loaded in the process in the WaitNextEvent method of the debugger.

if(dbgEvent.EventType == DebugEventType.LoadDllEvent)
{
    if(AreSameImage(dbgEvent.As<LoadDllDetail>().ImageName, "kernel32.dll"))
    {
        Debuggee.SetProcess();
        SymbolManager.LoadSymbolsFromExe(Debuggee.MainModule.FileName);
    }
    SymbolManager.LoadSymbolsFromExe(dbgEvent.As<LoadDllDetail>().ImageName);
}

Loading the symbols are easy:

public bool LoadSymbolsFromExe(string exePath)
{
    var pdb = Path.ChangeExtension(exePath, "pdb");
    if(File.Exists(pdb))
    {
        var source = LoadSymbols(pdb);
        _Sources.Add(Path.GetFileName(exePath), source);
        return true;
    }
    return false;
}

private DiaSource LoadSymbols(string pdbPath)
{
    if(!File.Exists(pdbPath))
        throw new FileNotFoundException(pdbPath);
    var diaSource = COMHelper.RegisterIfNotExistAndCreate(() => new DiaSource(), "msdia110.dll");
    diaSource.loadDataFromPdb(pdbPath);
    return diaSource;
}

RegisterIfNotExistAndCreate is just a method that will deploy the COM component automatically on the machine, so  the user does not have a weird exception.

The SymbolManager.FromName just need to get the right DIASource, fetch the right symbol, and return a nice plain old .NET object.

public SymbolInfo FromName(string moduleName, string name)
{
    return FromName(moduleName, name, null);
}
public SymbolInfo FromName(string moduleName, string name, SymTagEnum? type)
{
    _Debugger.EnsureProcessLoaded();
    DiaSource diaSource = FindSource(moduleName);
    IDiaSession session;
    IDiaEnumTables tables;
    diaSource.openSession(out session);
    session.getEnumTables(out tables);

    return tables.ToEnumerable()
        .OfType<IDiaEnumSymbols>()
        .SelectMany(s => s.ToEnumerable())
        .Where(s => s.name == name && (type == null || (uint)type.Value == s.symTag))
        .Select(s => new SymbolInfo(s))
        .FirstOrDefault();
}

Patching

What if you modified the behavior of a program and you want to persist the changes you made to the binary ?

You can also do that easily ! Each time that the debugger write on the Debuggee’s memory, the change is tracked by Debugger.Patches each Patch is one change, and you can decide to remove them, or apply them to binaries.

using(var dbg = new ProcessDebugger())
{
    dbg.Start(SimpleCrackMe);
    RVA mainRVA = dbg.SymbolManager.FromName("SimpleCrackMe.exe", "main").RVA; //Get main address
    Breakpoint breakPoint = dbg.Breakpoints.At("SimpleCrackMe.exe", mainRVA); //Set breakpoint
    dbg.BreakingThread.Continue.UntilBreakpoint(breakPoint); //Run to it
    var disasm = dbg.BreakingThread.Continue.UntilInstruction("JNZ"); //Continue execution until next JNZ
    dbg.BreakingThread.WriteInstruction("NOP"); //Nop the JNZ
    dbg.Patches.ModifyImage(dbg.Debuggee.MainModule, "SimpleCrackMe-Patched.exe"); //Save changes back to a file
    Assert.Equal(1, dbg.Continue.ToEnd()); //Debugged process resolved !
    var result = StartAndGetReturnCode("SimpleCrackMe-Patched.exe");
    Assert.Equal(1, result); //SimpleCrakMe-Patched also resolved !!!!
}

How it works under the hood need some explanation about how a dll or exe is stored in RAM vs on the disk.

A byte inside a dll have two locations : A file offset and an RVA. The file offset is its location on the disk file, RVA is its location in the RAM from the load address of your module.

Why is it ?
Two main reasons :

  • The file does not have to take space to store global variable. These values are decided at runtime.
  • The file need to be compact
  • On the other hand, the processor need to load sections of the DLL on page boundary (4 Kb) for security and performance reason. The processor can prevent code to execute within some pages, so that a buffer or stack overflow cannot be easily exploited.

Let’s take an example :
You can see with CFF explorer that an executable (dll or exe) have multiple sections.
The Virtual Address of the section is the RVA. The Raw Address is the File Offset.
Image [29]

The .text section is commonly where is the assembly code.
Now imagine that a method called IVssAdmin::RegisterProvider is in this section in the RAM at address E3BC.
Image [30]
Then, given the information about Raw Size and Raw Address (file offset) of the .text section, the IVssAdmin::RegisterProvider will be located at D7BC on the disk.

Image [31]

So here is the math to convert an RVA to a file offset.

E3BC (RVA) - 1000 (RVA of .text section) = D3BC (Relative address to the .text section)
D3BC + 400 (File offset of .text section) = D7BC (File Offset)

Or in code :

public FileOffset ToImageOffset(RVA rva)
{
    var section = GetSectionAt(rva);
    if(section == null)
        throw new InvalidOperationException("This RVA belongs to no section");
    var addressInSection = rva.Address - section.VirtualAddress;
    var offset = section.PointerToRawData + addressInSection;
    if(offset >= section.PointerToRawData + section.SizeOfRawData)
        throw new InvalidOperationException("This RVA is virtual");
    return new FileOffset(this, offset);
}

This is how a Patch converts tracked changes on RVA, changes on binary files.
----New : now NHook can find RVA from the export directory of dll.

Conclusion

Hope you liked it, I think it is not a bad thing that .NET developers understand what is going on on lower level. That’s the start of an API I will use for my own needs whenever I need a way to manipulate a process memory or behavior for whatever reason.

There are tons of important things to do like :

  • x64 supports
  • .NET supports

Check NHook home !

License

This article, along with any associated source code and files, is licensed under The GNU General Public License (GPLv3)

Share

About the Author

Nicolas Dorier
Software Developer Freelance
France France
I am a trainer and a curious developer.
 
CEO of AO-IS, we created a tool to make IaaS on Azure more easy IaaS Management Studio.
 
If you are interested for working with me, for fun coding stuff, for freelance stuff, or interested in using our cloud training infrastructure freely for a kickass presentation for the dev community ? this way Smile | :)

Comments and Discussions

 
GeneralMy vote of 5 PinmemberAntonio Petricca12-Jun-13 21:59 
GeneralRe: My vote of 5 PinmemberNicolas Dorier12-Jun-13 22:35 
GeneralMy vote of 5 PinmemberMihai MOGA14-Dec-12 5:11 
GeneralRe: My vote of 5 PinmemberNicolas Dorier14-Dec-12 6:07 
GeneralGreat PinmemberMohammad A Rahman6-Dec-12 13:48 
GeneralRe: Great PinmemberNicolas Dorier6-Dec-12 17:08 
GeneralOutstanding work! PinmemberMattias Högström4-Dec-12 20:33 
GeneralRe: Outstanding work! PinmemberNicolas Dorier4-Dec-12 20:57 
GeneralRe: Outstanding work! PinmemberNicolas Dorier4-Dec-12 20:59 
GeneralRe: Outstanding work! PinmemberMattias Högström7-Dec-12 7:13 
GeneralMy vote of 5 Pinmemberring_02-Dec-12 17:56 
GeneralRe: My vote of 5 PinmemberNicolas Dorier2-Dec-12 18:40 
Question64 bit ? Pinmemberemperon2-Dec-12 6:16 
AnswerRe: 64 bit ? PinmemberNicolas Dorier2-Dec-12 8:40 
GeneralMy vote of 5 PinmvpPaulo Zemek30-Nov-12 23:31 
GeneralRe: My vote of 5 PinmemberNicolas Dorier1-Dec-12 8:51 
GeneralRe: My vote of 5 PinmvpPaulo Zemek1-Dec-12 9:27 
GeneralRe: My vote of 5 PinmemberNicolas Dorier1-Dec-12 14:19 
GeneralRe: My vote of 5 PinmvpPaulo Zemek1-Dec-12 14:21 
QuestionWell Done ! PinmemberWhiteKnight30-Nov-12 17:16 
AnswerRe: Well Done ! PinmemberNicolas Dorier30-Nov-12 17:20 
GeneralVery cool PinmemberNoah Falk30-Nov-12 14:18 
GeneralRe: Very cool PinmemberNicolas Dorier30-Nov-12 15:06 
Hi Noah,
 
Thanks, Microsoft did a great job with all the debugging stuff ! I'm going to implement the a NDebugger class to do the same stuff I'm doing in x86, but with MSIL. Basically I want to make what sos.dll/sosex.dll does easy to do in .NET.
 
I'll keep you informed ! Smile | :)
GeneralRe: Very cool PinmemberNicolas Dorier30-Nov-12 17:36 
GeneralRe: Very cool PinmemberNoah Falk3-Dec-12 10:50 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Mobile
Web03 | 2.8.140916.1 | Last Updated 1 Dec 2012
Article Copyright 2012 by Nicolas Dorier
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid