All Articles

Analyzing a Base64 Program Implemented in C with WinDbg Time Travel Debugging

This page has been machine-translated from the original page.

This time I want to decompile a custom Base64 module with Ghidra and then trace through its execution with WinDbg.

Table of Contents

Building and Compiling the Base64 Program

First, let me build the program that will be used for analysis.

The source code is available in the following repository.

Reference: kash1064/Try2WinDbg

For the Base64 encode and decode program, I borrowed the implementation introduced in the article below, which is distributed under the WTFPL v2 license.

Reference: Functions for BASE64 Encoding and Decoding in C - Qiita

Some parts have been customized, but the base is largely unchanged.

The source code introduced in this article is also published under WTFPL v2 in keeping with the original, so feel free to use it.

Note: the correctness of the Base64 encoding and decoding results is not guaranteed.

Build with the following commands.

The developer command prompt must be configured in advance to enable cl.exe.

git clone https://github.com/kash1064/Try2WinDbg
cd Try2WinDbg

# cl.exeを利用できるように、事前に開発者用コマンドプロンプトを設定しておく
cl /c ./build/c/base64.c
link /DEBUG /PDB:./build/symbol/base64.pdb ./base64.obj /OUT:./build/bin/base64.exe

Even in environments where building is not possible, the TTD trace file inside Try2WinDbg/trace/base64_trace.zip can be used to perform TTD-based analysis.

For information on loading TTD traces, refer to the following article.

/Reference: WinDbg Preview: A New Debugging Approach with Time Travel Debugging

About the Base64 Implementation

What Is Base64?

Base64 is a data encoding method defined in RFC4648.

It converts arbitrary byte sequences into ASCII data, making it useful in many situations such as data storage and transfer.

Base64 splits the binary data to be encoded into 24-bit chunks and then further into 6-bit groups, converting the data to a predefined set of 64 characters.

When the binary data to be encoded is not a multiple of 24 bits in length, padding characters are appended.

The padding character is defined in the RFC as =, while URL-safe Base64 encoding (also defined in the RFC) uses _ instead.

Base64 Source Code

The following is the source code for the Base64 program used in this article.

It is fairly long, so detailed explanation is omitted.

Running the compiled program executes the tests defined in the main function, but individual functions can also be called by linking the module from another program.

#include <assert.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include "base64.h"

char *base64Encode(const char *data, const size_t size, const BASE64_TYPE type)
{
    BASE64_SPEC base64_spec;
    size_t length;
    char *base64;
    char *cursor;
    int lineLength;
    int i;
    int j;

    if (data == NULL) {
        return NULL;
    }

    // base64_specの初期化
    base64_spec = BASE64_SPECS[0];
    for (i = 0; i < (int)BASE64_SPECS_LENGTH; i++) {
        if (BASE64_SPECS[i].type == type) {
            base64_spec = BASE64_SPECS[i];
            break;
        }
    }

    // エンコード後の文字列格納領域の確保
    length = ((size * 4) / 3) + 3;

    // mallocの戻り値は確保したメモリブロックを指すポインタ
    base64 = malloc(length);
    if (base64 == NULL) {
        return NULL;
    }

    cursor = base64;
    lineLength = 0;

    // 3文字単位でエンコードを行う(エンコード後は4文字になる)
    for (i = 0, j = size; j > 0; i += 3, j -= 3) {
        if (j == 1) {
            *(cursor++) = base64_spec.table[(data[i + 0] >> 2 & 0x3f)];
            *(cursor++) = base64_spec.table[(data[i + 0] << 4 & 0x30)];
            *(cursor++) = base64_spec.pad;
            *(cursor++) = base64_spec.pad;
        }
        else if (j == 2) {
            *(cursor++) = base64_spec.table[(data[i + 0] >> 2 & 0x3f)];
            *(cursor++) = base64_spec.table[(data[i + 0] << 4 & 0x30) | (data[i + 1] >> 4 & 0x0f)];
            *(cursor++) = base64_spec.table[(data[i + 1] << 2 & 0x3c)];
            *(cursor++) = base64_spec.pad;
        }
        else {
            *(cursor++) = base64_spec.table[(data[i + 0] >> 2 & 0x3f)];
            *(cursor++) = base64_spec.table[(data[i + 0] << 4 & 0x30) | (data[i + 1] >> 4 & 0x0f)];
            *(cursor++) = base64_spec.table[(data[i + 1] << 2 & 0x3c) | (data[i + 2] >> 6 & 0x03)];
            *(cursor++) = base64_spec.table[(data[i + 2] << 0 & 0x3f)];
        }
    }
    *cursor = 0;

    return base64;
}

char *base64Decode(const char *base64, size_t *retSize, const BASE64_TYPE type)
{
    BASE64_SPEC base64_spec;
    char table[0x80];
    size_t length;
    char *data;
    char *cursor;
    int i;
    int j;

    if (base64 == NULL) {
        return NULL;
    }

    // base64_specの初期化
    base64_spec = BASE64_SPECS[0];
    for (i = 0; i < (int)BASE64_SPECS_LENGTH; i++) {
        if (BASE64_SPECS[i].type == type) 
        {
            base64_spec = BASE64_SPECS[i];
            break;
        }
    }

    // デコードするBase64文字列用のメモリ領域の確保
    length = strlen(base64);
    data = malloc(length * 3 / 4 + 3);
    if (data == NULL) {
        return NULL;
    }

    memset(table, 0x80, sizeof(table));
    for (i = 0; i < BASE64_TABLE_LENGTH; i++) {
        table[base64_spec.table[i] & 0x7f] = i;
    }

    cursor = data;
    for (i = 0, j = 0; i < (int)length; i++, j = i % 4) {
        char ch;
        if (base64[i] == base64_spec.pad)
        {
            break;
        }
        ch = table[base64[i] & 0x7f];
        if (ch & 0x80) {
            continue;
        }
        if (j == 0) {
            *cursor = ch << 2 & 0xfc;
        }
        else if (j == 1) {
            *(cursor++) |= ch >> 4 & 0x03;
            *cursor = ch << 4 & 0xf0;
        }
        else if (j == 2) {
            *(cursor++) |= ch >> 2 & 0x0f;
            *cursor = ch << 6 & 0xc0;
        }
        else {
            *(cursor++) |= ch & 0x3f;
        }
    }
    *cursor = 0;
    *retSize = cursor - data;

    return data;
}

int main(void) {
    int i;
    for (i = 0; i < (int)BASE64_TESTS_LENGTH; i++) {
        BASE64_TEST test;
        char *data;
        char *base64;
        size_t size;

        test = BASE64_TESTS[i];

        base64 = base64Encode(test.data, test.size, test.type);
        printf("BASE64(\"%s\") = \"%s\"\n", test.data, base64);
        assert(strcmp(base64, test.base64) == 0);

        data = base64Decode(base64, &size, test.type);
        printf("DATA(\"%s\") = \"%s\"\n", base64, data);
        assert(size == test.size);
        assert(memcmp(data, test.data, size) == 0);

        free(base64);
        free(data);
    }

    return 0;
}

Base64 Program Header File

The following is the header file for the Base64 program.

#ifndef __BASE64_H__
#define __BASE64_H__

// Data

// Base64 tables
static const char BASE64_TABLE[] = {
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"};

static const char BASE64_TABLE_URL[] = {
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"};

static const char BASE64_TABLE_CUSTOM1[] = {
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"};

static const int BASE64_TABLE_LENGTH = {
    sizeof(BASE64_TABLE) / sizeof(BASE64_TABLE[0]) - 1};

// enum型で使用するBase64Tableの種類を定義
typedef enum tagBASE64_TYPE
{
    BASE64_TYPE_STANDARD,
    BASE64_TYPE_URL,
    BASE64_TYPE_CUSTOM1
} BASE64_TYPE;

typedef struct tagBASE64_SPEC
{
    BASE64_TYPE type;
    const char *table;
    char pad;
    char *lineSep;
    int lineSepLength;
} BASE64_SPEC;

static const BASE64_SPEC BASE64_SPECS[] = {
    {BASE64_TYPE_STANDARD, BASE64_TABLE, '=', NULL, 0},
    {BASE64_TYPE_URL, BASE64_TABLE_URL, '=', NULL, 0},
    {BASE64_TYPE_CUSTOM1, BASE64_TABLE_CUSTOM1, '=', NULL, 0}
};

static const size_t BASE64_SPECS_LENGTH = {
    sizeof(BASE64_SPECS) / sizeof(BASE64_SPECS[0])
};

// Export function
char *base64Encode(const char *data, const size_t size, const BASE64_TYPE type);
char *base64Decode(const char *base64, size_t *retSize, const BASE64_TYPE type);

// Test data
typedef struct tagBASE64_TEST {
    BASE64_TYPE type;
    const char *data;
    size_t size;
    const char *base64;
} BASE64_TEST;

static const BASE64_TEST BASE64_TESTS[] = {
    {BASE64_TYPE_STANDARD, "this is test", 12, "dGhpcyBpcyB0ZXN0"},
    {BASE64_TYPE_STANDARD, "Hello", 5, "SGVsbG8="},
    {BASE64_TYPE_STANDARD, "Fan-Fan-Fun!!", 13, "RmFuLUZhbi1GdW4hIQ=="},
    {BASE64_TYPE_STANDARD, "AAA", 3, "QUFB"},
};

static const size_t BASE64_TESTS_LENGTH = {sizeof(BASE64_TESTS) / sizeof(BASE64_TESTS[0])};

#endif // !__BASE64_H__

Capturing a Time Travel Debugging Trace with WinDbg

After building, I captured a TTD trace to make analysis easier.

For the TTD trace capture procedure, refer to the following article.

/Reference: WinDbg Preview: A New Debugging Approach with Time Travel Debugging

Note that even in environments where the program cannot be built, the TTD trace file inside Try2WinDbg/trace/base64_trace.zip can be used for TTD-based analysis.

Decompiling the Base64 Program with Ghidra

Before analyzing the trace file, I first decompiled the Base64 program with Ghidra.

Identifying the main Function from the Entry Point

When executing a PE binary, the entry point runs initialization code before calling main, after which the exit process is called.

Reference: What Happens Before main() | Big Mess o’ Wires

/* 中略 */
FID_conflict:__get_initial_narrow_environment();
thunk_FUN_0043c3a2();
thunk_FUN_0043c39c();
unaff_ESI = thunk_FUN_004078f0();
uVar7 = ___scrt_is_managed_app();
if ((char)uVar7 != '\0') {
	if (!bVar2) {
    	__cexit();
    }
    ___scrt_uninitialize_crt('\x01','\0');
    *in_FS_OFFSET = local_14;
    return unaff_ESI;
}
/* 中略 */

In other words, from the decompiled output above, thunk_FUN_004078f0() — called on the line immediately before ___scrt_is_managed_app() — is the main function.

Reading the Decompiled Base64Encode Function

Next, I traced from the main function to the Base64Encode function and read its decompiled output.

Naturally, the code looks quite different from the source and is harder to follow.

char * __cdecl base64_encode(int param_1,int param_2,int param_3)

{
  char *pcVar1;
  char *local_2c;
  char local_28;
  int local_10;
  int local_c;
  char *local_8;
  
  if (param_1 == 0) {
    pcVar1 = (char *)0x0;
  }
  else {
    local_2c = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    local_28 = '=';
    for (local_c = 0; local_c < 3; local_c = local_c + 1) {
      if ((&DAT_00467f24)[local_c * 5] == param_3) {
        local_2c = (&PTR_s_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef_00467f28)[local_c * 5];
        local_28 = (char)(&DAT_00467f2c)[local_c * 5];
        break;
      }
    }
    pcVar1 = (char *)thunk_FUN_0041973b((uint)(param_2 << 2) / 3 + 3);
    if (pcVar1 == (char *)0x0) {
      pcVar1 = (char *)0x0;
    }
    else {
      local_c = 0;
      local_8 = pcVar1;
      for (local_10 = param_2; 0 < local_10; local_10 = local_10 + -3) {
        if (local_10 == 1) {
          *local_8 = local_2c[(int)*(char *)(param_1 + local_c) >> 2 & 0x3f];
          local_8[1] = local_2c[((int)*(char *)(param_1 + local_c) & 3U) * 0x10];
          local_8[2] = local_28;
          local_8[3] = local_28;
        }
        else if (local_10 == 2) {
          *local_8 = local_2c[(int)*(char *)(param_1 + local_c) >> 2 & 0x3f];
          local_8[1] = local_2c[((int)*(char *)(param_1 + local_c) & 3U) << 4 |
                                (int)*(char *)(param_1 + local_c + 1) >> 4 & 0xfU];
          local_8[2] = local_2c[((int)*(char *)(param_1 + local_c + 1) & 0xfU) * 4];
          local_8[3] = local_28;
        }
        else {
          *local_8 = local_2c[(int)*(char *)(param_1 + local_c) >> 2 & 0x3f];
          local_8[1] = local_2c[((int)*(char *)(param_1 + local_c) & 3U) << 4 |
                                (int)*(char *)(param_1 + local_c + 1) >> 4 & 0xfU];
          local_8[2] = local_2c[((int)*(char *)(param_1 + local_c + 1) & 0xfU) << 2 |
                                (int)*(char *)(param_1 + local_c + 2) >> 6 & 3U];
          local_8[3] = local_2c[(int)*(char *)(param_1 + local_c + 2) & 0x3f];
        }
        local_8 = local_8 + 4;
        local_c = local_c + 3;
      }
      *local_8 = '\0';
    }
  }
  return pcVar1;
}

One thing that caught my attention is this section:

pcVar1 = (char *)thunk_FUN_0041973b((uint)(param_2 << 2) / 3 + 3);
if (pcVar1 == (char *)0x0) {
	pcVar1 = (char *)0x0;
}

The corresponding source code is:

// エンコード後の文字列格納領域の確保
length = ((size * 4) / 3) + 3;

// mallocの戻り値は確保したメモリブロックを指すポインタ
base64 = malloc(length);
if (base64 == NULL) {
    return NULL;
}

First, I traced thunk_FUN_0041973b in Ghidra and found the following:

void FUN_0041973b(size_t param_1)
{
  __malloc_base(param_1);
  return;
}

From this, thunk_FUN_0041973b turned out to be the malloc function.

Reference: ucrtbase.dll | Microsoft C Runtime Library | STRONTIC

Therefore, (uint)(param_2 << 2) / 3 + 3 passed as the argument to malloc corresponds to the following line in the source code:

// エンコード後の文字列格納領域の確保
length = ((size * 4) / 3) + 3;

Interesting observations: the value that was originally stored in the variable length is passed directly to malloc in the decompiled output, and the size * 4 multiplication has been replaced with a shift operation.

When doing reverse engineering, it helps to understand these kinds of compiler transformations.

Reading the Decompiled Base64Decode Function

Next, let’s look at the decompiled output of the Decode function.

void __cdecl base64_decode(char *param_1,int *param_2,int param_3)

{
  byte bVar1;
  size_t sVar2;
  byte *pbVar3;
  byte *in_EDX;
  undefined8 uVar4;
  char *in_stack_ffffff50;
  char local_ac;
  uint local_98;
  byte *local_94;
  uint local_90;
  byte local_88 [128];
  uint local_8;
  
  local_8 = DAT_00474224 ^ (uint)&stack0xfffffffc;
  local_94 = in_EDX;
  if (param_1 != (char *)0x0) {
    in_stack_ffffff50 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    local_ac = '=';
    for (local_90 = 0; (int)local_90 < 3; local_90 = local_90 + 1) {
      if ((&DAT_00467f24)[local_90 * 5] == param_3) {
        in_stack_ffffff50 = (&PTR_s_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef_00467f28)[local_90 * 5];
        local_ac = (char)(&DAT_00467f2c)[local_90 * 5];
        break;
      }
    }
    sVar2 = _strlen(param_1);
    uVar4 = thunk_FUN_0041973b((sVar2 * 3 >> 2) + 3);
    local_94 = (byte *)((ulonglong)uVar4 >> 0x20);
    pbVar3 = (byte *)uVar4;
    if (pbVar3 != (byte *)0x0) {
      _memset(local_88,0x80,0x80);
      for (local_90 = 0; (int)local_90 < 0x40; local_90 = local_90 + 1) {
        local_88[(int)in_stack_ffffff50[local_90] & 0x7f] = (byte)local_90;
      }
      local_90 = 0;
      local_98 = 0;
      local_94 = pbVar3;
      while (((int)local_90 < (int)sVar2 && (param_1[local_90] != local_ac))) {
        bVar1 = local_88[(int)param_1[local_90] & 0x7f];
        if (((int)(char)bVar1 & 0x80U) == 0) {
          if (local_98 == 0) {
            *local_94 = (byte)(((int)(char)bVar1 & 0x3fU) << 2);
          }
          else if (local_98 == 1) {
            *local_94 = *local_94 | (char)bVar1 >> 4 & 3U;
            local_94 = local_94 + 1;
            *local_94 = (byte)(((int)(char)bVar1 & 0xfU) << 4);
          }
          else if (local_98 == 2) {
            *local_94 = *local_94 | (char)bVar1 >> 2 & 0xfU;
            local_94 = local_94 + 1;
            *local_94 = (byte)(((int)(char)bVar1 & 3U) << 6);
          }
          else {
            *local_94 = *local_94 | bVar1 & 0x3f;
            local_94 = local_94 + 1;
          }
        }
        local_90 = local_90 + 1;
        local_98 = local_90 & 0x80000003;
        if ((int)local_98 < 0) {
          local_98 = (local_98 - 1 | 0xfffffffc) + 1;
        }
      }
      *local_94 = 0;
      *param_2 = (int)local_94 - (int)pbVar3;
    }
  }
  thunk_FUN_00407ca8(local_8 ^ (uint)&stack0xfffffffc,(char)local_94,(char)in_stack_ffffff50);
  return;
}

Nothing particularly notable, but there is one section of the decompiled output that I could not fully understand:

sVar2 = _strlen(param_1);
uVar4 = thunk_FUN_0041973b((sVar2 * 3 >> 2) + 3);
local_94 = (byte *)((ulonglong)uVar4 >> 0x20);
pbVar3 = (byte *)uVar4;

This corresponds to the following source code:

// デコードするBase64文字列用のメモリ領域の確保
length = strlen(base64);
data = malloc(length * 3 / 4 + 3);
if (data == NULL) {
	return NULL;
}

Since thunk_FUN_0041973b is malloc, uVar4 from uVar4 = thunk_FUN_0041973b((sVar2 * 3 >> 2) + 3); corresponds to data in the source code.

malloc stores the starting address of the allocated memory block in the return value, so uVar4 holds an address.

Right-shifting it by 0x20 shifts the address by 32 bits.

I couldn’t figure out from the decompiled output why this shift is happening, so I planned to investigate further with WinDbg.

Analyzing the TTD Trace

Loading the Symbol File

After launching WinDbg and loading the TTD trace file, load the symbol file.

.sympath+ C:\Try2WinDbg\traces\base64_trace
.reload

Once the symbol file is loaded, functions can be searched by name.

>  x /D base64!base64*
002372d0          base64!base64Encode (_base64Encode)
002375a0          base64!base64Decode (_base64Decod

Using x /D module!function-name, the address of each function was found.

Setting Breakpoints

Next, set breakpoints at the call address of each function.

> bu base64!base64Encode
> bu base64!base64Decode
> bl
0 e Disable Clear  002372d0     0001 (0001)  0:**** base64!base64Encode
1 e Disable Clear  002375a0     0001 (0001)  0:**** base64!base64Decode

Running the g command now will stop execution at the call site of the first base64Encode function.

Aligning the Image Base Between WinDbg and Ghidra

From the lm output, you can see that the Base64 program is loaded at 0x230000.

> lm
start    end        module name
00230000 002ac000   base64_exe C (private pdb symbols)  C:\ProgramData\Dbg\sym\base64.pdb\E82F6C1FD64A46D7AD5845CF4BD1BF431\base64.pdb

To make it easier to cross-reference Ghidra’s decompiled output with WinDbg’s analysis, change Ghidra’s image base setting to 0x230000.

Ghidra’s base address can be changed during file import via [Options], or afterward by opening [Window] > [Memory Map] and clicking the [Set Image Base] button on the right.

Setting a Breakpoint at the Target Location

Now that Ghidra’s addresses are aligned with WinDbg’s, setting breakpoints becomes straightforward.

Let’s set a breakpoint at local_94 = (byte *)((ulonglong)uVar4 >> 0x20);, the line from the decompiled output whose behavior I could not determine.

image-68.png

Set a breakpoint at 0x00237696 with the following command:

> bu 0x00237696

Advancing execution with the g command, I reached the target address.

image-69.png

From here, I traced the execution flow.

Reading the Assembly

I read the assembly at the breakpoint location.

00237691 e8 94 9c        CALL       malloc                                           undefined malloc(size_t param_1)
        ff ff
00237696 83 c4 04        ADD        ESP,0x4
00237699 89 85 68        MOV        dword ptr [EBP + local_9c],EAX
        ff ff ff
0023769f 83 bd 68        CMP        dword ptr [EBP + local_9c],0x0
        ff ff ff 00
002376a6 75 07           JNZ        LAB_002376af

EAX contains the address returned by malloc.

This address is stored in [EBP + local_9c] and compared against 0.

This corresponds to the following source code:

data = malloc(length * 3 / 4 + 3);
if (data == NULL) {
    return NULL;
}

Unfortunately, I could not find the local_94 = (byte *)((ulonglong)uVar4 >> 0x20); operation from the Ghidra decompiled output in the actual assembly.

I also inspected the memory address contents, but malloc’s return value was simply stored as-is.

> dyd [ebp-0x98]
           3          2          1          0
          -------- -------- -------- --------
0059f800  00000000 11100000 00010110 01101000  00e01668

It’s probably a quirk of Ghidra’s decompiler, but I’m still puzzled about what led it to generate that shift operation.

Summary

I had originally planned to trace through the Base64 processing in the debugger step by step, but since there weren’t many interesting findings, I skipped that part.

Next I want to implement and debug something like RC4 or ROT13 encryption.