前言

本文仅供学习探讨之用,如果侵犯了您的权益请联系我删除。

工具

  1. Visual Studio Code
  2. Spy++

效果

水印效果图

子类化窗口绘制背景

对于背景的绘制,我们可以通过子类化窗口然后拦截WM_ERASEBKGND消息来实现,也就是说在窗口背景需要被重绘的时候,我们拦截这个消息,然后绘制自己的背景。

具体的描述可以看:WM_ERASEBKGND

具体实现如下:

子类化窗口

void HackRoutine(HMODULE selfModuleHandle)
{
    HWND notepadWindowHandle = nullptr;

    // 先遍历自身获取主窗口句柄
    ::EnumWindows(
        [](HWND windowHandle, LPARAM lParam) -> BOOL
        {
            DWORD processId = 0;

            ::GetWindowThreadProcessId(windowHandle, &processId);
            if (processId == ::GetCurrentProcessId())
            {
                *reinterpret_cast<HWND *>(lParam) = windowHandle;

                return FALSE;
            }

            return TRUE;
        },
        reinterpret_cast<LPARAM>(&notepadWindowHandle));
    if (nullptr == notepadWindowHandle)
    {
        ::OutputDebugString(_T("[!] Failed to find Notepad window handle\n"));
        return;
    }

    // 再获取编辑器窗口句柄
    auto editControlHandle = ::FindWindowEx(notepadWindowHandle, nullptr, _T("Edit"), nullptr);
    if (nullptr == editControlHandle)
    {
        ::OutputDebugString(_T("[!] Failed to find Edit control handle\n"));
        return;
    }

    // 子类化窗口,设置为我们自定义的窗口过程 WndProcHook
    WndProcOriginal = reinterpret_cast<decltype(WndProcOriginal)>(
        ::SetWindowLongPtr(
            editControlHandle,
            GWLP_WNDPROC,
            reinterpret_cast<LONG_PTR>(WndProcHook)));
    if (nullptr == WndProcOriginal)
    {
        ::OutputDebugString(_T("[!] Failed to set WndProcHook\n"));
        return;
    }

    // 标记窗口区域为无效,手动触发一次重绘
    //   第三个参数决定是否发送 WM_ERASEBKGND 消息,所以必须为TRUE
    ::InvalidateRect(editControlHandle, nullptr, TRUE);
}

然后在WndProcHook中处理WM_ERASEBKGND消息和WM_SIZE

switch (uMsg)
{
case WM_ERASEBKGND:
{
    // 创建背景图相关资源
    CreateBackgroundImage(hWnd, reinterpret_cast<HDC>(wParam));

    // 获取窗口区域信息
    RECT windowRect{};
    ::GetClientRect(hWnd, &windowRect);

    // 将我们绘制好的背景图绘制(复制)到窗口中
    //   s_backgroundMDC: 背景图对应的 内存设备上下文句柄(MDC)
    ::BitBlt(
        reinterpret_cast<HDC>(wParam),
        windowRect.left, windowRect.top, windowRect.right, windowRect.bottom,
        s_backgroundMDC,
        windowRect.left, windowRect.top, SRCCOPY);

    // NOTE: 这里用ReleaseDC释放 HDC 资源,使得后续不被WM_PAINT再次绘制背景而覆盖掉我们绘制的水印,权宜之计
    ::ReleaseDC(hWnd, reinterpret_cast<HDC>(wParam));

    return TRUE;
}
case WM_SIZE: // 窗口大小改变时就会触发WM_SIZE消息
{
    // 销毁背景图相关资源
    DestroyBackgroundImage();
}
}

到这里准备工作就完成了,接下来就是绘制水印了。

水印(背景)绘制

constexpr int fontSize = 200; // 字体大小
constexpr int fontExtra = 16; // 字符间距
constexpr int watermarkTextLength = ...; // 水印文本长度
// 水印字体
static std::unique_ptr<void, decltype(&::DeleteObject)> watermarkFont(
    ::CreateFont(fontSize, 0, 0, 0, FW_BOLD, FALSE, FALSE, FALSE, ANSI_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, DEFAULT_QUALITY, DEFAULT_PITCH | FF_DONTCARE, _T("Arial")),
    &::DeleteObject);
RECT windowRect{};

windowRect.right = width;
windowRect.bottom = height;

// 填充白色背景
::FillRect(s_backgroundMDC, &windowRect, reinterpret_cast<HBRUSH>(::GetStockObject(WHITE_BRUSH)));
// 设置文本背景为透明
::SetBkMode(s_backgroundMDC, TRANSPARENT);
// 设置文本颜色为灰色
::SetTextColor(s_backgroundMDC, RGB(230, 230, 230));
// 设置文本字体
::SelectObject(s_backgroundMDC, watermarkFont.get());
// 设置文本字符间距
::SetTextCharacterExtra(s_backgroundMDC, fontExtra);

// 由于DrawText进行居中绘制时字符间距不参与计算,所以这里手动调整一下窗口区域宽度,使得文本居中
windowRect.right -= (watermarkTextLength - 1) * fontExtra;
// 绘制水印文本
::DrawText(s_backgroundMDC, NAME_OR_PATH.data(), watermarkTextLength, &windowRect, DT_SINGLELINE | DT_CENTER | DT_VCENTER);

是不是非常的简单?然后我们注入到记事本中看一下效果。

水印绘制效果

可以看到效果是有的。但是当输入或者选择文本时,由于文本绘制会带上背景色(默认白色),对应行的背景就会被覆盖掉。

要解决这个问题,我们必须Hook文本绘制,在绘制前先用对应行的背景图来对当前行进行填充(擦除),然后将文本的背景色设置为透明(SetBkMode),最后再绘制文本。

也就是相当于我们把原本的白色填充背景改成了我们自定义的背景图填充。

然后我们再对FillRect进行Hook,通过填充范围判断是否为背景填充然后做对应的操作。

相当于把之前WM_ERASEBKGND消息里的操作给搬过来了,并且兼容WM_PAINT消息,不需要再调用ReleaseDC释放 HDC 资源了。

Hook文本绘制解决覆盖问题

具体实现如下:

BOOL WINAPI ExtTextOutWHook(HDC hdc, int x, int y, UINT options, CONST RECT *lprect, LPCWSTR lpString, UINT c, CONST INT *lpDx)
{
    // 检查当前是否为编辑器窗口
    auto hWnd = ::WindowFromDC(hdc);
    if (nullptr == hWnd || s_editControlHandle != hWnd || nullptr == lprect || nullptr == s_backgroundMDC)
        return ExtTextOutWOriginal(hdc, x, y, options, lprect, lpString, c, lpDx);

    // 获取窗口区域信息
    RECT windowRect{};
    ::GetClientRect(hWnd, &windowRect);

    // 去除 ETO_OPAQUE 选项,使得文本绘制时不填充背景
    if (options & ETO_OPAQUE)
        options &= ~ETO_OPAQUE;

    // 用背景图填充当前行
    ::BitBlt(
        hdc,
        lprect->left,
        lprect->top,
        lprect->right - lprect->left,
        lprect->bottom - lprect->top,
        s_backgroundMDC,
        lprect->left % windowRect.right,
        lprect->top % windowRect.bottom,
        SRCCOPY);

    // 设置文本背景为透明
    int oldMode = 0;
    // 这里判断当前绘制文本的背景色是否为白色,如果是则设置文本背景为透明,否则保持原样避免选中文本显示"错误"
    //   选中文本的背景色为 0xd77800 (蓝色)
    bool isNeedToSetBkMode = 0xffffff == ::GetBkColor(hdc);
    if (isNeedToSetBkMode)
        oldMode = ::SetBkMode(hdc, TRANSPARENT);

    // 绘制文本
    auto result = ExtTextOutWOriginal(hdc, x, y, options, lprect, lpString, c, lpDx);

    // 恢复文本背景
    if (isNeedToSetBkMode)
        ::SetBkMode(hdc, oldMode);

    return result;
}

int WINAPI FillRectHook(HDC hdc, CONST RECT *lprc, HBRUSH hbr)
{
    // 检查当前是否为编辑器窗口
    auto hWnd = ::WindowFromDC(hdc);
    if (nullptr == hWnd || s_editControlHandle != hWnd || nullptr == lprc || nullptr == s_backgroundMDC)
        return FillRectOriginal(hdc, lprc, hbr);

    // 获取窗口区域信息
    RECT windowRect{};
    ::GetClientRect(hWnd, &windowRect);

    // 判断是否不为背景填充
    if (0 != lprc->left || 0 != lprc->top || windowRect.right != lprc->right || windowRect.bottom != lprc->bottom)
        return FillRectOriginal(hdc, lprc, hbr);

    // 用背景图填充背景
    return ::BitBlt(hdc, 0, 0, windowRect.right, windowRect.bottom, s_backgroundMDC, 0, 0, SRCCOPY);
}

然后再来看下效果:

水印绘制效果

可以看到,文本在输入时不会再覆盖水印背景了。

不过滚动之后发现水印会跟着滚动,这是因为系统只会更新由ScrollDC函数返回的更新区域,详细可以看ScrollDC

所以我们还需要HookScrollDC函数,并替换返回的更新区域,使得背景标记为需要更新。

BOOL WINAPI ScrollDCHook(HDC hdc, int dx, int dy, CONST RECT *lprc, CONST RECT *lprcClip, HRGN hrgnUpdate, LPRECT lprcUpdate)
{
    auto hWnd = ::WindowFromDC(hdc);
    if (nullptr == hWnd || s_editControlHandle != hWnd || nullptr == lprc || nullptr == s_backgroundMDC)
        return ScrollDCOriginal(hdc, dx, dy, lprc, lprcClip, hrgnUpdate, lprcUpdate);

    auto result = ScrollDCOriginal(hdc, dx, dy, lprc, lprcClip, hrgnUpdate, lprcUpdate);

    // 默认更新区域为整个窗口,可以按需修改
    ::GetClientRect(hWnd, lprcUpdate);

    return result;
}

弄好之后再来看下效果:

水印绘制效果

可以看到水印已经不再会随着滚动而滚动了。

顺便测试一下图片背景:

图片背景绘制效果

透明选中背景

现在选中文本是用SetBkColor的颜色填充(蓝色 #0078D7)的,也就是选中文本时选中区域的背景色是会覆盖掉背景图。

我们先创建一个额外的MDC备用

if (nullptr == s_selectedMDC)
{
    s_selectedMDC = ::CreateCompatibleDC(hdc);
    if (nullptr == s_selectedMDC)
    {
        ::OutputDebugString(_T("[!] Failed to create selected compatible DC\n"));
        exit(1);
    }

    RECT windowRect{};
    if (!::GetClientRect(hWnd, &windowRect))
    {
        ::OutputDebugString(_T("[!] Failed to get client rect for selected dc\n"));
        exit(1);
    }

    s_selectedHBM = ::CreateCompatibleBitmap(hdc, windowRect.right, windowRect.bottom);
    if (nullptr == s_selectedHBM)
    {
        ::OutputDebugString(_T("[!] Failed to create selected compatible bitmap\n"));
        exit(1);
    }

    s_selectedBrush = ::CreateSolidBrush(0xd77800);
    if (nullptr == s_selectedBrush)
    {
        ::OutputDebugString(_T("[!] Failed to create selected brush\n"));
        exit(1);
    }

    ::SelectObject(s_selectedMDC, s_selectedHBM);
}

然后判断背景色是否为#0078D7,如果是,我们就在额外的MDC的对应行填充蓝色背景色。

之后先将背景图绘制到HDC上,再通过AlphaBlend并设置透明度将额外的MDC绘制到HDC上,这样选中文本时选中的背景色看起来就会是半透明的,也就不会覆盖掉背景图了。

具体实现如下:

BOOL WINAPI ExtTextOutWHook(HDC hdc, int x, int y, UINT options, CONST RECT *lprect, LPCWSTR lpString, UINT c, CONST INT *lpDx)
{
    auto hWnd = ::WindowFromDC(hdc);
    if (nullptr == hWnd || s_editControlHandle != hWnd || nullptr == lprect || nullptr == s_backgroundMDC)
        return ExtTextOutWOriginal(hdc, x, y, options, lprect, lpString, c, lpDx);

    RECT windowRect{};
    ::GetClientRect(hWnd, &windowRect);

    if (options & ETO_OPAQUE)
        options &= ~ETO_OPAQUE;

    ::BitBlt(
        hdc,
        lprect->left,
        lprect->top,
        lprect->right - lprect->left,
        lprect->bottom - lprect->top,
        s_backgroundMDC,
        lprect->left % windowRect.right,
        lprect->top % windowRect.bottom,
        SRCCOPY);

    if (0xd77800 == ::GetBkColor(hdc))
    {
        constexpr BLENDFUNCTION blendFunction{
            .BlendOp = AC_SRC_OVER,
            .BlendFlags = 0,
            .SourceConstantAlpha = SELECTED_TEXT_BACKGROUND_ALPHA,
            .AlphaFormat = AC_SRC_OVER};

        ::FillRect(s_selectedMDC, lprect, s_selectedBrush);

        ::AlphaBlend(
            hdc,
            lprect->left,
            lprect->top,
            lprect->right - lprect->left,
            lprect->bottom - lprect->top,
            s_selectedMDC,
            lprect->left % windowRect.right,
            lprect->top % windowRect.bottom,
            lprect->right - lprect->left,
            lprect->bottom - lprect->top,
            blendFunction);
    }

    auto oldMode = ::SetBkMode(hdc, TRANSPARENT);

    auto result = ExtTextOutWOriginal(hdc, x, y, options, lprect, lpString, c, lpDx);

    ::SetBkMode(hdc, oldMode);

    return result;
}

然后来看一下对比效果:

不透明选中背景 透明选中背景

看着效果还是可以的。

自动注入

最后再弄个自动注入的,方便装逼,代码这里就不贴出来了,有兴趣可以自己看下完整代码。

设定是不给参数则启动一个notepad.exe并且自动注入。

如果给--daemon-d参数则启动一个后台进程,然后轮询查找notepad.exe进程自动注入,每个进程仅注入一次。

自动注入效果

完整代码

完整代码已经上传到Github,有兴趣的可以自己查看。

仓库:MineNotepad

结语

其实想一想如果把ExtTextOutW替换成PolyTextOutW好像可以强行把Edit控件变成富文本编辑框。

获取需要渲染的文本然后通过染色规则计算出对应字符的颜色,再通过PolyTextOutW绘制出来(那我为什么不直接写个DX版的RichEdit呢.jpg)。

sticker_smile.jpg

那就这样了,有缘再见~