DEV Community

Bored
Bored

Posted on

I Built a Command-Line Spy++ to Log Windows Messages. Here's How.

If you've ever done serious Windows development, you've probably used or at least heard of Spy++. It's a classic tool, bundled with Visual Studio, that lets you see the torrent of window messages flying around an application in real-time. It's an indispensable utility for debugging stubborn UI problems.

But it has one major limitation that always bothered me: you can't easily export the message stream.

You can watch messages go by, but you can't save them to a file for later analysis, comparison, or to use as the basis for an automation script. I wanted a tool that could do just that—a "Spy++ for the terminal."

So, I built NotSpy++. This is the story of how it works, the problems I solved along the way, and where it could go next.

The Goal: A "Message Logger"

The mission was simple:

  1. Target any running application by its Process ID (PID).
  2. Hook the process and start capturing its window messages.
  3. On exit (Ctrl+C), dump all captured messages to a file.
  4. Offer both a high-performance binary format and a human-readable JSON format.

How It Works: The Architecture

You can't just "read" messages from another process. Windows security boundaries prevent that. To get inside, you need a legitimate mechanism to execute your own code within the target's memory space. For this, we use the Win32 API's global hooks.

The architecture has two main components:

  1. The Controller (NotSpy.exe): This is the user-facing command-line tool. It finds the target process, sets up the communication channel, and installs the hook.
  2. The Hook DLL (Hook.dll): This is a small dynamic library that Windows automatically injects into the target application's process when the hook is set. This DLL contains the code that actually runs alongside the target, capturing its messages.

The flow looks like this:

[ You ] ---> Runs `NotSpy64.exe <PID>`
   |
   +-----> [ NotSpy64.exe (Controller) ]
             | 1. Creates a Shared Memory block.
             | 2. Gets a handle to the Hook DLL and the address of the hook procedure within it.
             ` 3. Calls SetWindowsHookEx(..., hookProcAddress, dllHandle, threadId)
                    |
                    V
        [ Target Process (e.g., notepad.exe) ]
          | 1. The Windows OS sees the hook, finds `Hook64.dll` on disk, and loads it into Notepad's process space.
          | 2. The Hook Procedure inside the DLL starts executing on every message.
          | 3. It writes the captured message data into the Shared Memory block.
          `--------------------------------------------------+
                                                             |
             <-- Reads data from Shared Memory on exit <--+
Enter fullscreen mode Exit fullscreen mode

The Communication: Shared Memory

The hook DLL is running inside the target process, and the controller is running in its own. How do they talk? Standard output (printf, cout) from the DLL won't go to your console.

The classic solution is Shared Memory. The controller uses CreateFileMapping and MapViewOfFile to create a block of memory that can be accessed by both processes. The DLL gets a handle to this same memory block and writes the MessageInfo structs directly into it.

// From Shared.h - The data structure we pass back and forth
const int MAX_MESSAGES = 100000; // Buffer for 100k messages

struct MessageInfo {
    char   type;         // 'P' for Pre-processing, 'R' for Result (post-processing)
    HWND   hwnd;
    UINT   message;
    WPARAM wParam;
    LPARAM lParam;
    LRESULT result;      // Only used for post-processing messages
};

struct SharedData {
    int count; // How many messages have we captured?
    MessageInfo messages[MAX_MESSAGES];
};
Enter fullscreen mode Exit fullscreen mode

Hurdles & Evolutions

Building this wasn't a straight line. Here are some of the key challenges that shaped the final program.

Challenge 1: The 32-bit vs. 64-bit Divide

This was the first major roadblock. I compiled my tool as 64-bit and tried to spy on a 32-bit application. It failed silently. SetWindowsHookEx returned NULL, and GetLastError() wasn't much help. Why?

The Golden Rule: The architecture of your injected DLL must match the architecture of the target process.

  • A 32-bit process can only load 32-bit DLLs.
  • A 64-bit process can only load 64-bit DLLs.

My 64-bit Controller was trying to use a hook that would require loading a 64-bit DLL into a 32-bit process. Windows correctly forbids this.

The Solution: Compile everything twice. The project now produces two full sets of binaries:

  • NotSpy32.exe & Hook32.dll for spying on 32-bit targets.
  • NotSpy64.exe & Hook64.dll for spying on 64-bit targets.

The C++ source code is identical for both; we just use the different Visual Studio command prompts to compile for each architecture.

At this point i could already log Messages from the target process, but i wanted to get more info from the stream

Hook installed. Capturing messages...
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3943046,"wParam":2},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3943062,"wParam":0},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3944031,"wParam":2},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3944062,"wParam":0},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3945046,"wParam":2},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3945062,"wParam":0},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3946046,"wParam":2},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3946062,"wParam":0},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3947046,"wParam":2},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3947062,"wParam":0},
{"hwnd":656982,"lParam":0,"message_id":14,"message_name":"MSG_ID_14","time":3947359,"wParam":0},
{"hwnd":656982,"lParam":50126892,"message_id":13,"message_name":"MSG_ID_13","time":3947359,"wParam":7},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3948046,"wParam":2},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3948046,"wParam":0},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3949046,"wParam":2},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3949078,"wParam":0},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3950046,"wParam":2},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3950062,"wParam":0},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3951046,"wParam":2},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3951062,"wParam":0},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3952046,"wParam":2},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3952062,"wParam":0},
{"hwnd":656982,"lParam":0,"message_id":14,"message_name":"MSG_ID_14","time":3952375,"wParam":0},
{"hwnd":656982,"lParam":50126892,"message_id":13,"message_name":"MSG_ID_13","time":3952375,"wParam":7},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3953031,"wParam":2},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3953031,"wParam":0},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3954031,"wParam":2},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3954046,"wParam":0},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3955046,"wParam":2},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3955046,"wParam":0},
{"hwnd":919308,"lParam":50131860,"message_id":70,"message_name":"MSG_ID_70","time":3955937,"wParam":0},
{"hwnd":329592,"lParam":50131860,"message_id":70,"message_name":"MSG_ID_70","time":3955937,"wParam":0},
{"hwnd":656982,"lParam":50131860,"message_id":70,"message_name":"MSG_ID_70","time":3955937,"wParam":0},
{"hwnd":919308,"lParam":50131860,"message_id":71,"message_name":"MSG_ID_71","time":3955937,"wParam":0},
{"hwnd":329592,"lParam":50131860,"message_id":71,"message_name":"MSG_ID_71","time":3955937,"wParam":0},
{"hwnd":656982,"lParam":50131860,"message_id":71,"message_name":"MSG_ID_71","time":3955937,"wParam":0},
{"hwnd":919308,"lParam":7720,"message_id":28,"message_name":"MSG_ID_28","time":3955937,"wParam":1},
{"hwnd":329592,"lParam":7720,"message_id":28,"message_name":"MSG_ID_28","time":3955937,"wParam":1},
{"hwnd":656982,"lParam":7720,"message_id":28,"message_name":"MSG_ID_28","time":3955937,"wParam":1},
{"hwnd":656982,"lParam":0,"message_id":134,"message_name":"MSG_ID_134","time":3955937,"wParam":1},
{"hwnd":656982,"lParam":0,"message_id":6,"message_name":"MSG_ID_6","time":3955937,"wParam":1},
{"hwnd":329592,"lParam":656982,"message_id":647,"message_name":"MSG_ID_647","time":3955937,"wParam":23},
{"hwnd":656982,"lParam":-1073741809,"message_id":641,"message_name":"MSG_ID_641","time":3955937,"wParam":1},
{"hwnd":329592,"lParam":1073741824,"message_id":641,"message_name":"MSG_ID_641","time":3955937,"wParam":1},
{"hwnd":919308,"lParam":1073741824,"message_id":641,"message_name":"MSG_ID_641","time":3955937,"wParam":1},
{"hwnd":656982,"lParam":0,"message_id":642,"message_name":"MSG_ID_642","time":3955937,"wParam":2},
{"hwnd":656982,"lParam":0,"message_id":7,"message_name":"WM_SETFOCUS","time":3955937,"wParam":0},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3956046,"wParam":2},
{"hwnd":656982,"lParam":0,"message_id":127,"message_name":"MSG_ID_127","time":3956046,"wParam":0},
{"hwnd":656982,"lParam":49480547,"message_id":132,"message_name":"MSG_ID_132","time":3956109,"wParam":0},
{"hwnd":656982,"lParam":49415011,"message_id":132,"message_name":"MSG_ID_132","time":3956109,"wParam":0},
{"hwnd":656982,"lParam":33554433,"message_id":32,"message_name":"MSG_ID_32","time":3956109,"wParam":656982},
{"hwnd":656982,"lParam":48890728,"message_id":132,"message_name":"MSG_ID_132","time":3956125,"wParam":0},
{"hwnd":656982,"lParam":48890728,"message_id":132,"message_name":"MSG_ID_132","time":3956125,"wParam":0},
{"hwnd":656982,"lParam":33554433,"message_id":32,"message_name":"MSG_ID_32","time":3956125,"wParam":656982},
{"hwnd":656982,"lParam":48104301,"message_id":132,"message_name":"MSG_ID_132","time":3956125,"wParam":0},
{"hwnd":656982,"lParam":48104301,"message_id":132,"message_name":"MSG_ID_132","time":3956125,"wParam":0},
{"hwnd":656982,"lParam":33554433,"message_id":32,"message_name":"MSG_ID_32","time":3956125,"wParam":656982},
{"hwnd":656982,"lParam":47514482,"message_id":132,"message_name":"MSG_ID_132","time":3956125,"wParam":0},
{"hwnd":656982,"lParam":47514482,"message_id":132,"message_name":"MSG_ID_132","time":3956125,"wParam":0},
{"hwnd":656982,"lParam":33554433,"message_id":32,"message_name":"MSG_ID_32","time":3956125,"wParam":656982},
{"hwnd":656982,"lParam":47055733,"message_id":132,"message_name":"MSG_ID_132","time":3956125,"wParam":0},
{"hwnd":656982,"lParam":47055733,"message_id":132,"message_name":"MSG_ID_132","time":3956125,"wParam":0},
{"hwnd":656982,"lParam":33554433,"message_id":32,"message_name":"MSG_ID_32","time":3956125,"wParam":656982},
{"hwnd":656982,"lParam":46465915,"message_id":132,"message_name":"MSG_ID_132","time":3956125,"wParam":0},
{"hwnd":656982,"lParam":46465915,"message_id":132,"message_name":"MSG_ID_132","time":3956125,"wParam":0},
{"hwnd":656982,"lParam":33554433,"message_id":32,"message_name":"MSG_ID_32","time":3956125,"wParam":656982},
{"hwnd":656982,"lParam":45482882,"message_id":132,"message_name":"MSG_ID_132","time":3956125,"wParam":0},
{"hwnd":656982,"lParam":45220741,"message_id":132,"message_name":"MSG_ID_132","time":3956125,"wParam":0},
{"hwnd":656982,"lParam":33554433,"message_id":32,"message_name":"MSG_ID_32","time":3956125,"wParam":656982},
{"hwnd":656982,"lParam":45089670,"message_id":132,"message_name":"MSG_ID_132","time":3956125,"wParam":0},
{"hwnd":656982,"lParam":44827527,"message_id":132,"message_name":"MSG_ID_132","time":3956125,"wParam":0},
{"hwnd":656982,"lParam":33554433,"message_id":32,"message_name":"MSG_ID_32","time":3956140,"wParam":656982},
Enter fullscreen mode Exit fullscreen mode

Challenge 2: Making the Output Truly Useful

A raw binary dump is fast but unreadable. For debugging, I needed something I could actually look at. This led to adding a JSON output option.

When the user runs NotSpy.exe 1234 --json, the program sets a flag. On exit, instead of just dumping the messages.dat file, it iterates through the captured MessageInfo structs and builds a beautiful JSON array using the fantastic nlohmann/json single-header library. This also includes mapping the integer message codes (like 512) to their string names (WM_MOUSEMOVE) for maximum readability.

This gives the user the best of both worlds:

  • messages.dat: Super-fast, low-overhead binary capture. Perfect for performance-critical logging or for a replay tool to read.
  • messages.json: Human-readable, detailed output. Perfect for manual inspection and analysis.
[
    {
        "type": "P",
        "hwnd": 131362,
        "message_id": 512,
        "message_name": "WM_MOUSEMOVE",
        "wParam": 0,
        "lParam": 12583546,
        "decoded_params": {
            "x": 602,
            "y": 192
        }
    },
    {
        "type": "R",
        "hwnd": 131362,
        "message_id": 132,
        "message_name": "WM_NCHITTEST",
        "wParam": 0,
        "lParam": 12583546,
        "result": 1,
        "decoded_params": {
            "x_screen": 602,
            "y_screen": 192,
            "result_name": "HTCLIENT"
        }
    }
]
Enter fullscreen mode Exit fullscreen mode

How to Use It

The project is available on GitHub: https://github.com/qwerty32123/NotSpy

You can either build it yourself or grab a pre-compiled version from the Releases page.

1. Build (from source):
You'll need Visual Studio's command-line tools.

REM --- For 32-bit ---
cl /LD src\Hook.cpp src\Hook.def user32.lib /Fe:Hook32.dll
cl /EHsc /Iinclude src\NotSpy.cpp /Fe:NotSpy32.exe user32.lib gdi32.lib

REM --- For 64-bit ---
cl /LD src\Hook.cpp src\Hook.def user32.lib /Fe:Hook64.dll
cl /EHsc /Iinclude src\NotSpy.cpp /Fe:NotSpy64.exe user32.lib gdi32.lib
Enter fullscreen mode Exit fullscreen mode

2. Run:
Find the PID of your target app from Task Manager. Make sure to check its architecture (in the Details tab, right-click the columns header, select "Architecture").

# Spy on a 64-bit app and save to binary (default)
NotSpy64.exe 12345

# Spy on a 32-bit app and save to JSON
NotSpy32.exe 9876 --json
Enter fullscreen mode Exit fullscreen mode

Now, just use the target application. Move the mouse, click, type—all the messages are being logged. When you're done, hit Ctrl+C in the terminal to stop and save the file.

The Future: What's Next?

This tool is functional, but it's also a great foundation for more features. Here are some ideas I've been thinking about:

  • Message Filtering: Allow the user to specify which messages to include or exclude (e.g., --ignore=WM_MOUSEMOVE) to reduce log file size.
  • Real-time Streaming: Instead of saving on exit, stream the JSON output to the console in real-time.
  • A Simple TUI: Use a library like ncurses or FTXUI to build a Text-based User Interface that looks more like the classic Spy++.
  • An "All-in-One" Launcher: This is the big one. A single launcher (NotSpy.exe) that detects the target process's architecture and automatically runs the correct NotSpy32.exe or NotSpy64.exe helper process. This would eliminate the need for the user to manually check the target's architecture, vastly improving usability.

This project was a fantastic dive into the depths of the Win32 API, and it resulted in a tool I've already found truly useful. If any of these ideas sound interesting, I'd love to see contributions!

Check out the full source code and releases on GitHub:
https://github.com/qwerty32123/NotSpy

Thanks for reading

Top comments (0)