Research

Dynamic Binary Instrumentation with Intel Pin

A Powerful Tool for Reverse Engineers and Security Researchers

Some programs only make sense once they’re in motion. Reverse engineering gets easier when you can follow every step, not just guess the next one, and turn runtime behaviour into evidence you can trust.

Dynamic Binary Instrumentation with Intel Pin (1200)

Precision cuts tough targets

When you're reverse engineering a suspicious binary or solving a challenging CTF problem, sometimes static analysis just doesn't cut it. You need to see what's actually happening when the code runs, trace every instruction, monitor memory accesses, and understand the program's runtime behaviour without modifying the original executable. This is where Intel Pin Tool becomes an invaluable asset in your security toolkit.

What is Intel Pin?

Intel Pin is a dynamic binary instrumentation (DBI) framework that allows you to insert arbitrary code into running programs. Think of it as a sophisticated microscope for executable files that lets you observe and analyse program behaviour at an incredibly granular level without needing the source code or even recompiling the binary.

The framework works by intercepting a program's execution and inserting instrumentation code at various points, whether that's before specific instructions, after function calls, or during memory operations. What makes Pin particularly powerful is that it operates at the binary level, meaning you can analyse compiled executables regardless of the programming language they were written in or whether debug symbols are available.

Pin consists of two main components: the Pin runtime system itself, which handles the heavy lifting of code injection and program manipulation, and Pintools, which are the instrumentation plugins you write to define what you want to observe or modify. Intel provides Pin for Linux, Windows, and macOS platforms, making it versatile across different operating systems commonly encountered in security research.

The Architecture Behind the Magic

Understanding how Pin works helps appreciate its capabilities. When you run a program under Pin, the framework doesn't directly execute the original binary. Instead, it performs just-in-time (JIT) compilation on the executable code. Pin fetches sequences of instructions from the original program, adds your instrumentation code where specified, and then executes this instrumented version.

This approach provides several advantages. First, the original binary remains unchanged on disk, which is crucial when analysing malware that might detect modifications. Second, the instrumentation overhead, while not negligible, is generally manageable for most analysis tasks. Third, you get complete control over what aspects of execution you want to monitor without being limited by what a debugger typically exposes.

The Pin API is designed around the concept of instrumentation routines and analysis routines. Instrumentation routines run once when Pin encounters new code, allowing you to decide where to insert your analysis code. Analysis routines are the callbacks that execute during program runtime, collecting the actual data you're interested in. This two-phase approach enables efficient instrumentation by separating the "where to instrument" decision from the "what to collect" execution.

A Practical Example: Building Our First Pintool

Let's dive into a concrete example. Consider this simple target program that might represent a basic CTF crackme challenge:

// target.c
#include <stdio.h>
#include <string.h>

int check_password(char *input) {
    char secret[] = "sup3r_s3cr3t";
    return strcmp(input, secret);
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        printf("Usage: %s <password>\n", argv[0]);
        return 1;
    }

    printf("Checking password...\n");

    if (check_password(argv[1]) == 0) {
        printf("Access granted! Flag: CTF{you_found_me}\n");
    } else {
        printf("Access denied!\n");
    }

    return 0;
}

Compile this with MSVC:

cl /Od /MD target.c

Now, let's write a Pintool to trace all instruction executions and see what the program is doing:

// inscount.cpp - Simple instruction counter
#include "pin.H"
#include <iostream>
#include <fstream>

std::ofstream outFile;
UINT64 icount = 0;

// Analysis routine - called for every instruction
VOID docount() {
    icount++;
}

// Instrumentation routine - called once per instruction
VOID Instruction(INS ins, VOID *v) {
    INS_InsertCall(ins, IPOINT_BEFORE, (AFUNPTR)docount, IARG_END);
}

// Called when program exits
VOID Fini(INT32 code, VOID *v) {
    outFile << "Total instructions executed: " << icount << std::endl;
    outFile.close();
}

INT32 Usage() {
    std::cerr << "This tool counts the number of instructions executed" << std::endl;
    return -1;
}

int main(int argc, char *argv[]) {
    if (PIN_Init(argc, argv)) return Usage();

    outFile.open("inscount.out");

    INS_AddInstrumentFunction(Instruction, 0);
    PIN_AddFiniFunction(Fini, 0);

    PIN_StartProgram();
    return 0;
}

To compile the Pintool on Windows, copy the code above to:

source\tools\MyPinTool

Then run make:

C:\Users\ctfman\Documents\Reflare\pintool\source\tools\MyPinTool>make TARGET=intel64
mkdir -p obj-intel64/
make objects
make[1]: Entering directory '/cygdrive/c/Users/ctfman/Documents/Reflare/pintool/source/tools/MyPinTool'
make[1]: Nothing to be done for 'objects'.
make[1]: Leaving directory '/cygdrive/c/Users/ctfman/Documents/Reflare/pintool/source/tools/MyPinTool'
make libs
make[1]: Entering directory '/cygdrive/c/Users/ctfman/Documents/Reflare/pintool/source/tools/MyPinTool'
make[1]: Nothing to be done for 'libs'.
make[1]: Leaving directory '/cygdrive/c/Users/ctfman/Documents/Reflare/pintool/source/tools/MyPinTool'
make dlls
make[1]: Entering directory '/cygdrive/c/Users/ctfman/Documents/Reflare/pintool/source/tools/MyPinTool'
make[1]: Nothing to be done for 'dlls'.
make[1]: Leaving directory '/cygdrive/c/Users/ctfman/Documents/Reflare/pintool/source/tools/MyPinTool'
make apps
make[1]: Entering directory '/cygdrive/c/Users/ctfman/Documents/Reflare/pintool/source/tools/MyPinTool'
make[1]: Nothing to be done for 'apps'.
make[1]: Leaving directory '/cygdrive/c/Users/ctfman/Documents/Reflare/pintool/source/tools/MyPinTool'
make tools
make[1]: Entering directory '/cygdrive/c/Users/ctfman/Documents/Reflare/pintool/source/tools/MyPinTool'
../../../intel64/pinrt/bin/pin-clang-cl++ -Wno-non-c-typedef-for-linkage -Wno-microsoft-include -Wno-unicode -I../../../source/include/pin -I../../../source/include/pin/gen -I../../../intel64/pinrt/include/adaptor -I../../../extras/components/include -I../../../extras/xed-intel64/include/xed -I../../../source/tools/Utils -MD -O2 -c -Foobj-intel64/inscount.obj inscount.cpp
../../../intel64/pinrt/bin/pin-ld -dll -c++ -EXPORT:main -INCREMENTAL:NO -IGNORE:4210 -IGNORE:4049 -DYNAMICBASE -NXCOMPAT -OPT:REF -out:obj-intel64/inscount.dll obj-intel64/inscount.obj -LIBPATH:../../../intel64/lib -LIBPATH:../../../extras/xed-intel64/lib pin.lib pinrt-adaptor-static.lib xed.lib kernel32.lib
make[1]: Leaving directory '/cygdrive/c/Users/ctfman/Documents/Reflare/pintool/source/tools/MyPinTool'

C:\Users\ctfman\Documents\Reflare\pintool\source\tools\MyPinTool>

Please note that you must have GNU make (from Cygwin) and clang-cl already installed. You can download those tools at the following links:

  • https://www.cygwin.com/
  • https://github.com/llvm/llvm-project/releases

Also, don't forget to edit the makefile.rules and change the TEST_TOOL_ROOTS line to:

TEST_TOOL_ROOTS := inscount

Run it:

pin.exe -t source\tools\MyPinTool\obj-intel64\inscount.dll -- target.exe wrongpassword
C:\Users\ctfman\Documents\Reflare>pintool\pin.exe -t pintool\source\tools\MyPinTool\obj-intel64\inscount.dll -- target.exe wrongpasswd
Checking password...
Access denied!

C:\Users\ctfman\Documents\Reflare>type inscount.out
Total instructions executed: 2156764

C:\Users\ctfman\Documents\Reflare>pintool\pin.exe -t pintool\source\tools\MyPinTool\obj-intel64\inscount.dll -- target.exe sup3r_s3cr3t
Checking password...
Access granted! Flag: CTF{you_found_me}

C:\Users\ctfman\Documents\Reflare>type inscount.out
Total instructions executed: 2158778

C:\Users\ctfman\Documents\Reflare>

This basic example counts instructions, but let's create something more useful for CTF analysis.

Advanced Example: Tracing String Comparisons

For the strcmp tracer to work, we need the target to dynamically link the C runtime. This is critical: on modern Windows with MSVC, the /MD flag links against ucrtbase.dll (the Universal CRT), which Pin can see and instrument. Without /MD, the compiler may statically embed strcmp into the binary, making it invisible to function-level hooks. Note that Pin can still intercept inlined comparisons through instruction-level instrumentation (e.g., hooking the repe cmpsb instructions that MSVC emits for inlined strcmp), but we use function-level hooking here to keep things simple.

cl /Od /MD target.c

Where:

  • /Od: disables optimisation (prevents inlining)
  • /MD: links against the dynamic CRT (ucrtbase.dll), ensuring strcmp is an actual DLL call that Pin can intercept

Now, here's a more powerful Pintool that specifically targets string comparison functions - exactly what we need to crack our password checker:

// strcmp_tracer.cpp
#include "pin.H"
#include <iostream>
#include <fstream>
#include <string>

std::ofstream outFile;

std::string SafeReadString(ADDRINT addr, size_t maxLen = 256) {
    std::string result;
    char c;
    for (size_t i = 0; i < maxLen; i++) {
        if (PIN_SafeCopy(&c, reinterpret_cast<const void*>(addr + i), 1) != 1)
            break;
        if (c == '\0') break;
        result += c;
    }
    if (result.empty()) result = "<unreadable>";
    return result;
}

std::string SafeReadWString(ADDRINT addr, size_t maxLen = 256) {
    std::string result;
    wchar_t wc;
    for (size_t i = 0; i < maxLen; i++) {
        if (PIN_SafeCopy(&wc, reinterpret_cast<const void*>(addr + i * sizeof(wchar_t)), sizeof(wchar_t)) != sizeof(wchar_t))
            break;
        if (wc == L'\0') break;
        result += (char)wc;
    }
    if (result.empty()) result = "<unreadable>";
    return result;
}

VOID BeforeStrCmp(ADDRINT arg1, ADDRINT arg2, const char *funcName) {
    outFile << funcName << " called:" << std::endl;
    outFile << " Arg1: \"" << SafeReadString(arg1) << "\"" << std::endl;
    outFile << " Arg2: \"" << SafeReadString(arg2) << "\"" << std::endl;
    outFile << std::endl;
    outFile.flush();
}

VOID BeforeMemCmp(ADDRINT arg1, ADDRINT arg2, ADDRINT size, const char *funcName) {
    outFile << funcName << " called (size=" << size << "):" << std::endl;
    outFile << " Arg1: \"" << SafeReadString(arg1, (size_t)size) << "\"" << std::endl;
    outFile << " Arg2: \"" << SafeReadString(arg2, (size_t)size) << "\"" << std::endl;
    outFile << std::endl;
    outFile.flush();
}

VOID BeforeWcsCmp(ADDRINT arg1, ADDRINT arg2, const char *funcName) {
    outFile << funcName << " called:" << std::endl;
    outFile << " Arg1: \"" << SafeReadWString(arg1) << "\"" << std::endl;
    outFile << " Arg2: \"" << SafeReadWString(arg2) << "\"" << std::endl;
    outFile << std::endl;
    outFile.flush();
}

VOID HookStrCmp(IMG img, const char *name) {
    RTN rtn = RTN_FindByName(img, name);
    if (RTN_Valid(rtn)) {
        RTN_Open(rtn);
        RTN_InsertCall(rtn, IPOINT_BEFORE, (AFUNPTR)BeforeStrCmp,
                       IARG_FUNCARG_ENTRYPOINT_VALUE, 0,
                       IARG_FUNCARG_ENTRYPOINT_VALUE, 1,
                       IARG_ADDRINT, (ADDRINT)name,
                       IARG_END);
        RTN_Close(rtn);
        outFile << "[*] Instrumented " << name << " in " << IMG_Name(img) << std::endl;
    }
}

VOID HookMemCmp(IMG img, const char *name) {
    RTN rtn = RTN_FindByName(img, name);
    if (RTN_Valid(rtn)) {
        RTN_Open(rtn);
        RTN_InsertCall(rtn, IPOINT_BEFORE, (AFUNPTR)BeforeMemCmp,
                       IARG_FUNCARG_ENTRYPOINT_VALUE, 0,
                       IARG_FUNCARG_ENTRYPOINT_VALUE, 1,
                       IARG_FUNCARG_ENTRYPOINT_VALUE, 2,
                       IARG_ADDRINT, (ADDRINT)name,
                       IARG_END);
        RTN_Close(rtn);
        outFile << "[*] Instrumented " << name << " in " << IMG_Name(img) << std::endl;
    }
}

VOID HookWcsCmp(IMG img, const char *name) {
    RTN rtn = RTN_FindByName(img, name);
    if (RTN_Valid(rtn)) {
        RTN_Open(rtn);
        RTN_InsertCall(rtn, IPOINT_BEFORE, (AFUNPTR)BeforeWcsCmp,
                       IARG_FUNCARG_ENTRYPOINT_VALUE, 0,
                       IARG_FUNCARG_ENTRYPOINT_VALUE, 1,
                       IARG_ADDRINT, (ADDRINT)name,
                       IARG_END);
        RTN_Close(rtn);
        outFile << "[*] Instrumented " << name << " in " << IMG_Name(img) << std::endl;
    }
}

VOID Image(IMG img, VOID *v) {
    outFile << "[IMG] Loaded: " << IMG_Name(img) << std::endl;
    outFile.flush();

    // Standard string compares
    HookStrCmp(img, "strcmp");
    HookStrCmp(img, "_stricmp");
    HookStrCmp(img, "_strcmpi");
    HookStrCmp(img, "lstrcmpA");
    HookStrCmp(img, "lstrcmpiA");

    // Bounded compares (3 args)
    HookMemCmp(img, "strncmp");
    HookMemCmp(img, "_strnicmp");
    HookMemCmp(img, "memcmp");

    // Wide-char compares
    HookWcsCmp(img, "wcscmp");
    HookWcsCmp(img, "_wcsicmp");
    HookWcsCmp(img, "lstrcmpW");
    HookWcsCmp(img, "lstrcmpiW");
    HookMemCmp(img, "wcsncmp");
    HookMemCmp(img, "_wcsnicmp");
}

VOID Fini(INT32 code, VOID *v) {
    outFile.close();
}

INT32 Usage() {
    std::cerr << "This tool traces string/memory compare calls" << std::endl;
    return -1;
}

int main(int argc, char *argv[]) {
    PIN_InitSymbols();
    if (PIN_Init(argc, argv)) return Usage();

    outFile.open("strcmp_trace.out");

    IMG_AddInstrumentFunction(Image, 0);
    PIN_AddFiniFunction(Fini, 0);

    PIN_StartProgram();
    return 0;
}
C:\Users\ctfman\Documents\Reflare>pintool\pin.exe -t pintool\source\tools\MyPinTool\obj-intel64\strcmp_tracer.dll -- target.exe sup3r_scr3t
Checking password...
Access denied!
C:\Users\ctfman\Documents\Reflare>type strcmp_trace.out
[IMG] Loaded: C:\WINDOWS\System32\ucrtbase.dll
[*] Instrumented strcmp in C:\WINDOWS\System32\ucrtbase.dll
[*] Instrumented _stricmp in C:\WINDOWS\System32\ucrtbase.dll
[*] Instrumented strncmp in C:\WINDOWS\System32\ucrtbase.dll
[*] Instrumented _strnicmp in C:\WINDOWS\System32\ucrtbase.dll
[*] Instrumented memcmp in C:\WINDOWS\System32\ucrtbase.dll
[*] Instrumented wcscmp in C:\WINDOWS\System32\ucrtbase.dll
[*] Instrumented _wcsicmp in C:\WINDOWS\System32\ucrtbase.dll
[*] Instrumented wcsncmp in C:\WINDOWS\System32\ucrtbase.dll
[*] Instrumented _wcsnicmp in C:\WINDOWS\System32\ucrtbase.dll
[IMG] Loaded: C:\WINDOWS\SYSTEM32\VCRUNTIME140.dll
[*] Instrumented memcmp in C:\WINDOWS\SYSTEM32\VCRUNTIME140.dll
strcmp called:
 Arg1: "sup3r_scr3t"
 Arg2: "sup3r_s3cr3t"
[IMG] Loaded: C:\WINDOWS\SYSTEM32\kernel.appcore.dll
[IMG] Loaded: C:\WINDOWS\System32\msvcrt.dll
[*] Instrumented strcmp in C:\WINDOWS\System32\msvcrt.dll
[*] Instrumented _stricmp in C:\WINDOWS\System32\msvcrt.dll
[*] Instrumented _strcmpi in C:\WINDOWS\System32\msvcrt.dll
[*] Instrumented strncmp in C:\WINDOWS\System32\msvcrt.dll
[*] Instrumented _strnicmp in C:\WINDOWS\System32\msvcrt.dll
[*] Instrumented memcmp in C:\WINDOWS\System32\msvcrt.dll
[*] Instrumented wcscmp in C:\WINDOWS\System32\msvcrt.dll
[*] Instrumented _wcsicmp in C:\WINDOWS\System32\msvcrt.dll
[*] Instrumented wcsncmp in C:\WINDOWS\System32\msvcrt.dll
[*] Instrumented _wcsnicmp in C:\WINDOWS\System32\msvcrt.dll

And just like that, the secret password is right there in the trace output. No manual disassembly required.

CTF Example: Tracing a Serial Key Validator

Let's create a more challenging CTF challenge that uses multiple validation steps:

// serial_checker.c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

int validate_format(char *serial) {
    // Serial must be in format: XXXX-YYYY-ZZZZ
    if (strlen(serial) != 14) return 0;
    if (serial[4] != '-' || serial[9] != '-') return 0;
    return 1;
}

int validate_checksum(char *serial) {
    int sum = 0;
    for (int i = 0; i < 14; i++) {
        if (serial[i] != '-') {
            sum += serial[i];
        }
    }
    // Secret checksum
    return (sum == 1242); // Valid serial: C0D3-~~~~-~~~~
}

int validate_magic(char *serial) {
    // First 4 chars must be "C0D3"
    return (strncmp(serial, "C0D3", 4) == 0);
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        printf("Usage: %s <serial-key>\n", argv[0]);
        return 1;
    }

    printf("Validating serial key...\n");

    if (!validate_format(argv[1])) {
        printf("Invalid format!\n");
        return 1;
    }

    if (!validate_magic(argv[1])) {
        printf("Invalid magic bytes!\n");
        return 1;
    }

    if (!validate_checksum(argv[1])) {
        printf("Invalid checksum!\n");
        return 1;
    }

    printf("Valid serial! Flag: CTF{d1d_y0u_us3_p1n?}\n");
    return 0;
}

Compile:

cl /Od /MD serial_checker.c

Now let's write a Pintool to trace function calls and see the validation flow:

// calltrace.cpp
#include "pin.H"
#include <iostream>
#include <fstream>
#include <string>

std::ofstream outFile;
UINT32 callDepth = 0;

VOID BeforeCall(ADDRINT target, ADDRINT sp) {
    for (UINT32 i = 0; i < callDepth; i++) {
        outFile << " ";
    }
    outFile << "CALL -> 0x" << std::hex << target << std::dec << std::endl;
    callDepth++;
}

VOID AfterCall() {
    if (callDepth > 0) callDepth--;
}

VOID Instruction(INS ins, VOID *v) {
    if (INS_IsCall(ins)) {
        INS_InsertCall(ins, IPOINT_BEFORE, (AFUNPTR)BeforeCall,
                       IARG_BRANCH_TARGET_ADDR,
                       IARG_REG_VALUE, REG_STACK_PTR,
                       IARG_END);

        if (INS_HasFallThrough(ins)) {
            INS_InsertCall(ins, IPOINT_AFTER, (AFUNPTR)AfterCall, IARG_END);
        }
    }
}

VOID Fini(INT32 code, VOID *v) {
    outFile.close();
}

int main(int argc, char *argv[]) {
    if (PIN_Init(argc, argv)) return -1;

    outFile.open("calltrace.out");

    INS_AddInstrumentFunction(Instruction, 0);
    PIN_AddFiniFunction(Fini, 0);

    PIN_StartProgram();
    return 0;
}

Compile:

copy calltrace.cpp source\tools\MyPinTool\
cd source\tools\MyPinTool
make TARGET=intel64

Run it:

pin.exe -t source\tools\MyPinTool\obj-intel64\calltrace.dll -- serial_checker.exe AAAA-BBBB-CCCC
strings calltrace.out | head -n 10

CALL -> 0x7ff95331a2a0
 CALL -> 0x7ff9534658e0
  CALL -> 0x7ff953307900
   CALL -> 0x7ff953396380
    CALL -> 0x7ff95330d880
     CALL -> 0x7ff953395ea0
      CALL -> 0x7ff95336eae8
       CALL -> 0x7ff95333c190
        CALL -> 0x7ff9533f7970
        CALL -> 0x7ff9533f7970

This reveals the exact sequence of validation functions being called, helping you understand the program flow. Be warned: calltrace logs every single CALL instruction in the entire process, including deep within system DLLs. Even for a trivial program, expect the output file to be hundreds of megabytes. Ours was close to 700MB.

CTF Example: Memory Access Tracking for XOR Decryption

Many CTF challenges use XOR encoding. Here's an example that decodes a flag at runtime:

// xor_decoder.c
#include <stdio.h>
#include <string.h>

void decode_flag(char *input) {
    char encoded[] = {0x32, 0x2b, 0x2c, 0x1d, 0x2b, 0x31, 0x1d, 0x23, 0x35,
                      0x27, 0x31, 0x2d, 0x2f, 0x27, 0x63, 0x63};
    char decoded[20];
    int key = 0x42;

    // XOR decode
    for (int i = 0; i < 16; i++) {
        decoded[i] = encoded[i] ^ key;
    }
    decoded[16] = '\0';

    // Check if input matches decoded flag
    if (strcmp(input, decoded) == 0) {
        printf("Correct! Flag: CTF{%s}\n", decoded);
    } else {
        printf("Wrong!\n");
    }
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        printf("Usage: %s <flag>\n", argv[0]);
        return 1;
    }

    decode_flag(argv[1]);
    return 0;
}

Compile:

cl /Od /MD xor_decoder.c

Now let's track memory writes to see where the decoded flag appears. There are a couple of subtleties to get right here. First, a naive approach that instruments every write in the process will drown in noise. System DLLs alone can produce hundreds of thousands of writes before your code even runs. We use IMG_IsMainExecutable to grab the target binary's address range and filter out everything else. Second, if we read the written value at IPOINT_BEFORE (before the instruction executes), we'll see the old contents of memory, not the new value. We solve this by saving the write address before the instruction and reading the value after it completes:

// memtrace.cpp
#include "pin.H"
#include <iostream>
#include <fstream>

std::ofstream outFile;
UINT32 writeCount = 0;
ADDRINT mainLow = 0, mainHigh = 0;

static TLS_KEY tls_key;

struct WriteInfo {
    VOID *ip;
    VOID *addr;
    UINT32 size;
};

VOID RecordMemWriteBefore(VOID *ip, VOID *addr, UINT32 size, THREADID tid) {
    ADDRINT rip = (ADDRINT)ip;
    if (rip < mainLow || rip > mainHigh) return;

    WriteInfo *info = static_cast<WriteInfo*>(PIN_GetThreadData(tls_key, tid));
    info->ip = ip;
    info->addr = addr;
    info->size = size;
}

VOID RecordMemWriteAfter(THREADID tid) {
    WriteInfo *info = static_cast<WriteInfo*>(PIN_GetThreadData(tls_key, tid));
    if (!info->addr) return;

    writeCount++;

    unsigned char buf[64];
    UINT32 len = info->size > 64 ? 64 : info->size;
    PIN_SafeCopy(buf, info->addr, len);

    outFile << "WRITE at " << info->ip
            << " addr=" << info->addr
            << " size=" << std::dec << info->size << " ";

    for (UINT32 i = 0; i < len; i++) {
        outFile << (char)((buf[i] >= 0x20 && buf[i] <= 0x7E) ? buf[i] : '.');
    }

    outFile << std::endl;

    info->addr = NULL;
}

VOID Instruction(INS ins, VOID *v) {
    if (INS_IsMemoryWrite(ins) && INS_HasFallThrough(ins)) {
        INS_InsertCall(
            ins, IPOINT_BEFORE, (AFUNPTR)RecordMemWriteBefore,
            IARG_INST_PTR,
            IARG_MEMORYWRITE_EA,
            IARG_MEMORYWRITE_SIZE,
            IARG_THREAD_ID,
            IARG_END);

        INS_InsertCall(
            ins, IPOINT_AFTER, (AFUNPTR)RecordMemWriteAfter,
            IARG_THREAD_ID,
            IARG_END);
    }
}

VOID Image(IMG img, VOID *v) {
    if (IMG_IsMainExecutable(img)) {
        mainLow = IMG_LowAddress(img);
        mainHigh = IMG_HighAddress(img);
    }
}

VOID ThreadStart(THREADID tid, CONTEXT *ctxt, INT32 flags, VOID *v) {
    WriteInfo *info = new WriteInfo();
    info->addr = NULL;
    PIN_SetThreadData(tls_key, info, tid);
}

VOID ThreadFini(THREADID tid, const CONTEXT *ctxt, INT32 code, VOID *v) {
    WriteInfo *info = static_cast<WriteInfo*>(PIN_GetThreadData(tls_key, tid));
    delete info;
}

VOID Fini(INT32 code, VOID *v) {
    outFile << "\nTotal memory writes (main exe only): " << writeCount << std::endl;
    outFile.close();
}

int main(int argc, char *argv[]) {
    PIN_InitSymbols();
    if (PIN_Init(argc, argv)) return -1;

    tls_key = PIN_CreateThreadDataKey(NULL);

    outFile.open("memtrace.out");

    IMG_AddInstrumentFunction(Image, 0);
    INS_AddInstrumentFunction(Instruction, 0);
    PIN_AddFiniFunction(Fini, 0);
    PIN_AddThreadStartFunction(ThreadStart, 0);
    PIN_AddThreadFiniFunction(ThreadFini, 0);

    PIN_StartProgram();
    return 0;
}

Compile:

copy memtrace.cpp source\tools\MyPinTool\
cd source\tools\MyPinTool
make TARGET=intel64

Run it:

pin.exe -t source\tools\MyPinTool\obj-intel64\memtrace.dll -- xor_decoder.exe test

Here's the output (trimmed for brevity):

WRITE at 0x7ff792561018 addr=0xdfb66ff900 size=1 2
WRITE at 0x7ff79256101d addr=0xdfb66ff901 size=1 +
WRITE at 0x7ff792561022 addr=0xdfb66ff902 size=1 ,
WRITE at 0x7ff792561027 addr=0xdfb66ff903 size=1 .
WRITE at 0x7ff79256102c addr=0xdfb66ff904 size=1 +
WRITE at 0x7ff792561031 addr=0xdfb66ff905 size=1 1
WRITE at 0x7ff792561036 addr=0xdfb66ff906 size=1 .
WRITE at 0x7ff79256103b addr=0xdfb66ff907 size=1 #
WRITE at 0x7ff792561040 addr=0xdfb66ff908 size=1 5
WRITE at 0x7ff792561045 addr=0xdfb66ff909 size=1 '
WRITE at 0x7ff79256104a addr=0xdfb66ff90a size=1 1
WRITE at 0x7ff79256104f addr=0xdfb66ff90b size=1 -
WRITE at 0x7ff792561054 addr=0xdfb66ff90c size=1 /
WRITE at 0x7ff792561059 addr=0xdfb66ff90d size=1 '
WRITE at 0x7ff79256105e addr=0xdfb66ff90e size=1 c
WRITE at 0x7ff792561063 addr=0xdfb66ff90f size=1 c
WRITE at 0x7ff792561068 addr=0xdfb66ff8f4 size=4 B...
WRITE at 0x7ff792561070 addr=0xdfb66ff8f0 size=4 ....
WRITE at 0x7ff79256109e addr=0xdfb66ff910 size=1 p
WRITE at 0x7ff792561080 addr=0xdfb66ff8f0 size=4 ....
WRITE at 0x7ff79256109e addr=0xdfb66ff911 size=1 i
WRITE at 0x7ff792561080 addr=0xdfb66ff8f0 size=4 ....
WRITE at 0x7ff79256109e addr=0xdfb66ff912 size=1 n
WRITE at 0x7ff792561080 addr=0xdfb66ff8f0 size=4 ....
WRITE at 0x7ff79256109e addr=0xdfb66ff913 size=1 _
WRITE at 0x7ff792561080 addr=0xdfb66ff8f0 size=4 ....
WRITE at 0x7ff79256109e addr=0xdfb66ff914 size=1 i
WRITE at 0x7ff792561080 addr=0xdfb66ff8f0 size=4 ....
WRITE at 0x7ff79256109e addr=0xdfb66ff915 size=1 s
WRITE at 0x7ff792561080 addr=0xdfb66ff8f0 size=4 ....
WRITE at 0x7ff79256109e addr=0xdfb66ff916 size=1 _
WRITE at 0x7ff792561080 addr=0xdfb66ff8f0 size=4 ....
WRITE at 0x7ff79256109e addr=0xdfb66ff917 size=1 a
WRITE at 0x7ff792561080 addr=0xdfb66ff8f0 size=4 ....
WRITE at 0x7ff79256109e addr=0xdfb66ff918 size=1 w
WRITE at 0x7ff792561080 addr=0xdfb66ff8f0 size=4 ....
WRITE at 0x7ff79256109e addr=0xdfb66ff919 size=1 e
WRITE at 0x7ff792561080 addr=0xdfb66ff8f0 size=4 ....
WRITE at 0x7ff79256109e addr=0xdfb66ff91a size=1 s
WRITE at 0x7ff792561080 addr=0xdfb66ff8f0 size=4 ....
WRITE at 0x7ff79256109e addr=0xdfb66ff91b size=1 o
WRITE at 0x7ff792561080 addr=0xdfb66ff8f0 size=4 ....
WRITE at 0x7ff79256109e addr=0xdfb66ff91c size=1 m
WRITE at 0x7ff792561080 addr=0xdfb66ff8f0 size=4 ....
WRITE at 0x7ff79256109e addr=0xdfb66ff91d size=1 e
WRITE at 0x7ff792561080 addr=0xdfb66ff8f0 size=4 ....
WRITE at 0x7ff79256109e addr=0xdfb66ff91e size=1 !
WRITE at 0x7ff792561080 addr=0xdfb66ff8f0 size=4 ....
WRITE at 0x7ff79256109e addr=0xdfb66ff91f size=1 !
WRITE at 0x7ff792561080 addr=0xdfb66ff8f0 size=4 ....

Total memory writes (main exe only): 130

The interesting part starts at instruction address 0x7ff79256109e. Notice how it repeats. A single byte written to consecutive stack addresses (0xdfb66ff910 through 0xdfb66ff91f). The same instruction address repeating confirms this is a loop body. Reading the ASCII column straight down gives us the decoded flag: pin_is_awesome!!. The interleaved 4-byte writes at 0xdfb66ff8f0 are the loop counter i incrementing each iteration.

By filtering to only the main executable's address range, we cut out all the noise from system DLLs and are left with just 130 writes, all from our target's own code.

Pin in CTF Competitions

The examples above demonstrate exactly why Pin is so valuable in CTF competitions:

  • Password crackers: The strcmp tracer reveals passwords instantly
  • Serial key validators: Call tracing shows the validation logic flow
  • Encoded flags: Memory tracing catches XOR decoding and other transformations
  • Anti-debugging: Pin often bypasses these since it's not a traditional debugger
  • Obfuscated comparisons: Even if the comparison logic is complex, Pin sees the actual values at runtime

Consider other CTF scenarios where Pin excels:

Timing-based challenges: You can instrument timing-related functions to understand delays or race conditions.

Custom encryption: By tracking memory operations, you can watch data transform through encryption routines without understanding the algorithm.

Input validation: Trace where your input goes and what transformations it undergoes.

Practical Considerations

Writing effective Pintools requires balancing comprehensive instrumentation against performance. Instrumenting every instruction creates massive overhead. Instead, instrument selectively - focus on specific functions, memory regions, or instruction types relevant to your analysis.

The compilation process requires the Pin toolkit installed on your system. Each Pintool compiles against Pin's headers and links with its libraries.

One limitation to understand: Pin introduces timing differences. Instrumented code runs slower than native execution, which can affect malware using timing-based anti-analysis or causing race conditions to behave differently. However, for most CTF scenarios, the insights gained far outweigh these limitations.

The Competitive Landscape

Pin isn't alone in the DBI space. DynamoRIO offers similar capabilities with different performance characteristics, while Frida provides a more scripting-friendly approach using JavaScript. Pin's advantages include mature documentation, excellent instruction-level granularity, and strong Intel support. Its C++ API provides maximum control and performance for complex instrumentation tasks.

Conclusion

The examples we've explored, from simple instruction counting to targeted strcmp tracing to memory access tracking, demonstrate how Pin enables rapid analysis of binaries that would otherwise require tedious manual disassembly. Start with simple Pintools like the ones shown here, then build more sophisticated instrumentation as you encounter harder challenges. The code examples provided here serve as a foundation. Modify them, combine them, and build your own custom analysis tools tailored to your specific needs.

Subscribe by email