Reverse Engineering Network Protocols

Many applications implement bespoke binary protocols for network communications. Being able to reverse engineer how a protocol works will allow you to target parts of the application that would otherwise be inaccessible. In this article, we’re going to look at reverse engineering a very simple protocol. Source code for the application is available at the end of the article.

First, let’s start by debugging the application to find which part of the code is responsible for parsing network traffic. Load the application in WinDBG Preview, and use the lm command to find the currently loaded modules;

0:004> lm
start    end        module name
00a80000 00aa1000   SimpleTCPServer C (private pdb symbols)  C:\ProgramData\Dbg\sym\SimpleTCPServer.pdb\6D32CFA3C1604A66BE332ABE75C39FD11\SimpleTCPServer.pdb
561b0000 56326000   ucrtbased   (deferred)             
56330000 5634e000   VCRUNTIME140D   (deferred)             
72ff0000 7308f000   apphelp    (deferred)             
73380000 733d2000   mswsock    (deferred)             
760a0000 76103000   WS2_32     (deferred)             
76370000 76589000   KERNELBASE   (deferred)             
766d0000 7678e000   RPCRT4     (deferred)             
772a0000 77390000   KERNEL32   (export symbols)       C:\WINDOWS\System32\KERNEL32.DLL
77520000 776c4000   ntdll      (pdb symbols)          C:\ProgramData\Dbg\sym\wntdll.pdb\57A5FC91763644189F8C0088D7B2DBFC1\wntdll.pdb

Since it appears the standard WinSock (WS2_32) library is being used, we can set a breakpoint on it’s receive function.

0:004> bp ws2_32!recv
0:004> g
Breakpoint 0 hit
eax=00000200 ebx=00c20000 ecx=00eff5bc edx=00000110 esi=00eff344 edi=00eff9c0
eip=760b23a0 esp=00eff330 ebp=00eff9c0 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
WS2_32!recv:
760b23a0 8bff            mov     edi,edi

We can then generate some network traffic using ncat.

ncat 127.0.0.1  2600 -v
Ncat: Version 7.92 ( https://nmap.org/ncat )
Ncat: Connected to 127.0.0.1:2600.
AAAAA

When the breakpoint triggers, we can use the dd esp command to view the parameters supplied to the recv function.

0:000> dd esp L4
00eff330  00a91d63 00000110 00eff5bc 00000200
0:000> da 00eff5bc
00eff5bc  "AAAA."
0:000> ? 00000200
Evaluate expression: 512 = 00000200

The above output shows the memory address 0x00eff5bc is being used to store characters sent from the client. The value next to that (0x200) is the number of bytes that will be accepted by the socket in one go.

We know this is the order of the functions arguments based on the documented function parameters:

int recv(
  [in]  SOCKET s,
  [out] char   *buf,
  [in]  int    len,
  [in]  int    flags
);

Stepping through execution with the p command we end up in the main server code.

0:000> p
eax=00000005 ebx=00c20000 ecx=00000002 edx=00000000 esi=00eff344 edi=00eff9c0
eip=00a91d63 esp=00eff344 ebp=00eff9c0 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
SimpleTCPServer!main+0x3a3:
00a91d63 3bf4            cmp     esi,esp

At this point, we know the memory address of the main module that is going to be performing processing on the data sent (0x00a91d63). To examine the programs logic in IDA Pro we will need to syncronise IDA’s memory addresses with the currently running server being debugged using WinDBG.

lm m SimpleTCPServer
Browse full module list
start    end        module name
00a80000 00aa1000   SimpleTCPServer C (private pdb symbols)  C:\ProgramData\Dbg\sym\SimpleTCPServer.pdb\6D32CFA3C1604A66BE332ABE75C39FD11\SimpleTCPServer.pdb

In IDA Pro, select Edit > Segments > Rebase Program, and add the start address from the above output.

Hitting the g key in IDA will then allow us to jump to the same memory address were currently looking at in WinDBG.

From reviewing the IDA output, we can see a decision appears to take place based on the data being sent;

By adding a breakpoint just before this block, we can see that our first byte of input is being read and the number 0x41 is being subtracted from it. The result is zero, which then branches execution to the left. This is a common assembly construct for a switch statement being implemented in a higher level language.

0:000> g
Breakpoint 2 hit
eax=00000041 ebx=00c20000 ecx=00000041 edx=56335a84 esi=00eff344 edi=00eff9c0
eip=00a91e1b esp=00eff344 ebp=00eff9c0 iopl=0         nv up ei pl nz ac pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000216
SimpleTCPServer!main+0x45b:
00a91e1b 83e941          sub     ecx,41h
0:000> r ecx
ecx=00000041
0:000> p
Breakpoint 1 hit
eax=00000041 ebx=00c20000 ecx=00000000 edx=56335a84 esi=00eff344 edi=00eff9c0
eip=00a91e2d esp=00eff344 ebp=00eff9c0 iopl=0         nv up ei ng nz ac po cy
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000293
SimpleTCPServer!main+0x46d:
00a91e2d 8b9590f9ffff    mov     edx,dword ptr [ebp-670h] ss:002b:00eff350=00000000

We know that 0x41 (or the capital letter A in ASCII) leads us down the first branch of execution. The first, second and third branches do not appear to do anything interesting. However, the last branch takes a size input, and executes an additional function. As such it warrents further investigation. Based on the previous analysis, we can reach this area of code by entering 0x44 as our first input character.

Examining the function we have routed to, we can see a memcpy operation being performed:

We set a breakpoint in the application before the memcpy call, and send the data “DCBA”, we can see the size parameter seems to be populated based on the second byte of data being sent:

0:004> bp 00A91859
0:004> g
Breakpoint 0 hit
eax=00000043 ebx=00d21000 ecx=00baf498 edx=00baf1f4 esi=00baf220 edi=00baf210
eip=00a91859 esp=00baf118 ebp=00baf210 iopl=0         nv up ei pl nz ac pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000216
SimpleTCPServer!vulnFunction+0x59:
00a91859 e8b8faffff      call    SimpleTCPServer!ILT+785(_memcpy) (00a91316)
0:000> r eax
eax=00000043

So we have learnt the following:

  • The application takes upto 512 bytes of user input.
  • By entering 0x44 (‘D’ in ASCII) as the first character we can route execution to a memcpy function.
  • Based on the previous output, the second byte is supplied as the size parameter to the memcpy function.

Due to these conditions, the application seems suseptible to a stack based buffer overflow. I’ll leave exploiting that as an exercise for the reader 🙂

Closing Thoughts

The above example shows simple network protocol analysis. I’ve provided the C++ vulnerable server code below if you would like to follow along with this tutorial.

C++ Server Code

#undef UNICODE

#define WIN32_LEAN_AND_MEAN

#include <windows.h>
#include <winsock2.h>
#include <ws2tcpip.h>
#include <stdlib.h>
#include <stdio.h>
#include <string>
#pragma comment (lib, "Ws2_32.lib")

#define DEFAULT_BUFLEN 512
#define DEFAULT_PORT "2600"

void vulnFunction(char sourcebuffer[], int length)
{
    char output_buffer[20];
    printf("In vulnerable function!\n");
    printf("Number of bytes to copy: %i\n", length);
    memcpy(output_buffer,sourcebuffer, length);
}

int __cdecl main(void)
{
    WSADATA wsaData;
    int iResult;

    SOCKET ListenSocket = INVALID_SOCKET;
    SOCKET ClientSocket = INVALID_SOCKET;

    struct addrinfo* result = NULL;
    struct addrinfo hints;

    int iSendResult;
    char recvbuf[DEFAULT_BUFLEN];
    int recvbuflen = DEFAULT_BUFLEN;

    iResult = WSAStartup(MAKEWORD(2, 2), &wsaData);
    if (iResult != 0) {
        printf("WSAStartup failed with error: %d\n", iResult);
        return 1;
    }

    ZeroMemory(&hints, sizeof(hints));
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_protocol = IPPROTO_TCP;
    hints.ai_flags = AI_PASSIVE;

    iResult = getaddrinfo(NULL, DEFAULT_PORT, &hints, &result);
    if (iResult != 0) {
        printf("getaddrinfo failed with error: %d\n", iResult);
        WSACleanup();
        return 1;
    }

    ListenSocket = socket(result->ai_family, result->ai_socktype, result->ai_protocol);
    if (ListenSocket == INVALID_SOCKET) {
        printf("socket failed with error: %ld\n", WSAGetLastError());
        freeaddrinfo(result);
        WSACleanup();
        return 1;
    }

    iResult = bind(ListenSocket, result->ai_addr, (int)result->ai_addrlen);
    if (iResult == SOCKET_ERROR) {
        printf("bind failed with error: %d\n", WSAGetLastError());
        freeaddrinfo(result);
        closesocket(ListenSocket);
        WSACleanup();
        return 1;
    }

    freeaddrinfo(result);

    iResult = listen(ListenSocket, SOMAXCONN);
    if (iResult == SOCKET_ERROR) {
        printf("listen failed with error: %d\n", WSAGetLastError());
        closesocket(ListenSocket);
        WSACleanup();
        return 1;
    }

    ClientSocket = accept(ListenSocket, NULL, NULL);
    if (ClientSocket == INVALID_SOCKET) {
        printf("accept failed with error: %d\n", WSAGetLastError());
        closesocket(ListenSocket);
        WSACleanup();
        return 1;
    }

    printf("Listening...\n");

    closesocket(ListenSocket);

    do {

        iResult = recv(ClientSocket, recvbuf, recvbuflen, 0);
        if (iResult > 0) {
            printf("----------------------------------\n");
            printf("Number of bytes received: %d\n", iResult);
            // Add NULL byte to we can process client input as string
            recvbuf[iResult] = '\00';
            printf("Recieved: %s\n", recvbuf);

            int opcode = recvbuf[0];
            printf("Opcode: 0x%X\n", opcode);

            switch (opcode)
            {
            case 0x41:
                printf("Opcode 1 called\n");
                break;
            case 0x42:
                printf("Opcode 2 called\n");
                break;
            case 0x43:
                printf("Opcode 3 called\n");
                break;
            case 0x44:
                printf("Opcode 4 called\n");
                vulnFunction(recvbuf, recvbuf[1]);
                break;
            }

            iSendResult = send(ClientSocket, recvbuf, iResult, 0);
            printf("Bytes sent: %s\n", recvbuf);

            if (iSendResult == SOCKET_ERROR) {
                printf("send failed with error: %d\n", WSAGetLastError());
                closesocket(ClientSocket);
                WSACleanup();
                return 1;
            }
            printf("Bytes sent: %d\n", iSendResult);
        }
        else if (iResult == 0)
            printf("Connection closing...\n");
        else {
            printf("recv failed with error: %d\n", WSAGetLastError());
            closesocket(ClientSocket);
            WSACleanup();
            return 1;
        }

    } while (iResult > 0);

    iResult = shutdown(ClientSocket, SD_SEND);
    if (iResult == SOCKET_ERROR) {
        printf("shutdown failed with error: %d\n", WSAGetLastError());
        closesocket(ClientSocket);
        WSACleanup();
        return 1;
    }

    closesocket(ClientSocket);
    WSACleanup();
    return 0;
}