☰
用Go和Win32 Syscall实现Windows星号密码查看器
2026/10/5 4:14:19 网站建设 项目流程

做 Windows 开发或者安全测试的朋友,应该都遇到过这种尴尬:某天打开一个老软件、一个内部系统,或者一个很久没用的工具,登录框里明明是密码,却只显示一排星号。你记得大概是什么,但就是想不起完整明文。网上搜出来的“星号密码查看器”要么是老旧的闭源小软件,要么带着各种壳、各种推广,动不动给你装全家桶。所以我干脆花了点时间,用纯 Go + Win32 Syscall + Shellcode 注入,自己写了一个“Windows 星号密码查看器”,可以读取本机指定窗口内密码框的真实明文。这篇文章就从我的实际踩坑过程出发,把这个项目从需求、原理、代码到调试的全过程拆开来讲,适合正在学 Windows 底层调用、对 Go 调用 Win32 API 有兴趣、或者需要做 UI 自动化/密码找回工具的朋友。

1. 这个项目到底解决什么问题

1.1 一次“想不起来密码”的现场

事情起因很简单:公司内部有一套老旧的资产管理系统,基于 C/S 架构,登录界面是标准的 Win32 控件。系统里有个“记住密码”选项,登录的时候自动填充账号密码。后来我换了新电脑,需要在新机器上配置客户端,但我自己忘了密码明文。按常理说,我可以用“忘记密码”流程找 IT 重置,但那个系统没有自助找回功能,流程得走两三天。

于是我想:密码明文就“存在”这台电脑的登录框里,既然能看到星号,就应该有办法把它读出来。整个过程不涉及绕过任何认证、不涉及入侵他人系统,纯粹是读取本机当前登录用户桌面上一个窗口控件的内部文本。这个需求,本质上就是做一个“本地 UI 信息提取工具”。它和那些偷偷抓取别人聊天工具密码的恶意软件,行为上有明确边界:它只读取你当前桌面上可见窗口的密码框内容,而且只在你自己的会话里操作。

1.2 星号背后不只是一串星号

先说说密码框本身。Windows 标准 Edit 控件有一个样式叫 ES_PASSWORD,数值是 0x20。一旦控件设置了这种样式,用户输入时窗口就会显示星号或者圆点,同时系统对它的文本读取做了保护。很多人以为“密码框里的星号只是显示层,用 SendMessage 发一条 WM_GETTEXT 就能拿到明文”,实际上这个想法如果在同一个进程里写程序,确实成立,因为控件自己收到 WM_GETTEXT 消息时会返回真实文本;但跨进程场景下,Windows 会直接告诉你“别想拿”,你收到的只是一串星号。

为什么?因为这是系统层面的安全策略。ES_PASSWORD 样式下,系统不能让随便一个进程枚举完窗口句柄就把密码读走,那任何一个小工具都能当盗号器用了。Windows 对该控件的文本缓冲区做了隔离,跨进程读取时,返回的是实际显示的占位符,而不是内部缓冲区里的明文。这就是为什么很多简单的“星号查看器”只对同一进程自己创建的密码框有效,换个真正的第三方程序就失灵了。

1.3 纯 Go、Win32 Syscall、Shellcode 注入这三个词怎么串起来

看完场景,再看标题里的三个关键词:纯 Go、Win32 Syscall、Shellcode 注入。这三个词正好对应了一条完整的技术链路。

先说纯 Go。我不想用 C++/C 写这种小工具,原因很现实:一是编译链太沉重,写个几十 KB 的小工具要装 Visual Studio;二是交付不方便,对方机器上不一定有运行库。Go 交叉编译方便,一条命令可以同时出 32 位和 64 位 Windows 版本,静态链接后单文件跑起来就行,很适合这种一次性工具。

接着是 Win32 Syscall。Go 里直接调用 Windows API 不像 C 里那样直接 include 头文件,而是通过 syscall 或者 golang.org/x/sys/windows 这两个包,以 LazyDLL 的方式加载 user32.dll、kernel32.dll,再按函数签名手动声明参数。这个环节是整个项目最琐碎的部分,因为 Win32 API 的参数类型很多,Go 的 uintptr 传参一不小心就写错。

最后是 Shellcode 注入。跨进程读密码框明文,本质问题是:我没办法让目标进程“心甘情愿”地把明文交给我,我只能想办法把自己的代码送进目标进程,在里面完成读取动作,再把结果传回来。这个“把代码送进目标进程”的动作,就是经典的 shellcode 注入:先在目标进程里分配一块内存,写入一段位置无关的机器码,然后创建一个远程线程让它执行。这样代码就以目标进程的身份、权限和上下文运行,系统会认为这是目标进程内部的合法读取。

所以这三者不是随便凑在一起的:纯 Go 解决“怎么写出一个工具”,Win32 Syscall 解决“怎么和系统对话”,Shellcode 注入解决“怎么跨进程把密码取回来”。接下来我把每个环节拆开讲。

2. 方案选型:为什么是注入而不是其他方案

2.1 跨进程 SendMessage 为什么拿不到明文

有人会问:既然目标窗口句柄都能拿到,为什么不直接在外部进程里给密码框发 SendMessage?我前面提了一句系统会返回星号,但这背后的机制值得展开讲。

Windows 对 Edit 控件文本的跨进程读取,走的是一条“代理”链路。你调用 SendMessage 发送 WM_GETTEXT 时,消息会进入目标窗口所在线程的消息队列,由目标进程内的窗口过程来处理。按常理说,目标窗口过程在处理 WM_GETTEXT 时,应该把内部缓冲区里的真实文本拷出来。但 Windows 在 Edit 控件里加入了一个特殊判断:当控件带有 ES_PASSWORD 样式,并且调用方进程和控件所在进程不是同一个进程,系统就把返回内容换成显示文本,也就是那串星号。这个判断是在系统内部的控件实现里完成的,你没办法通过改参数、变消息绕过。

有人可能尝试用 EM_GETLINE、EM_GETSEL 之类的消息,但在同样场景下,这些都被系统的安全策略挡了回去。还有人说“用 ReadProcessMemory 直接读控件内存”,但问题是你在外部根本不知道真实文本存在哪块地址里,Edit 控件的内部结构由系统管理,没有公开稳定的偏移可读。加上现在 Windows 还有 ASLR、堆隔离等一系列缓解机制,直接去碰内存基本是瞎摸索。

2.2 SetWindowsHookEx 钩子方案为什么不够好

最经典的“星号密码查看器”实现其实是 SetWindowsHookEx,挂钩 WH_CALLWNDPROC 或 WH_GETMESSAGE,在消息流动时把 WM_GETTEXT 相关消息的参数截下来。这个方案能拿到明文,因为钩子代码会加载进目标进程,所以读取是“本地读取”,绕开了跨进程保护。

但它有几个让我不能忍的缺陷。第一,必须要有一个 DLL 作为钩子模块,纯 Go 默认不导出 DLL,写一个可注入的 Go DLL 非常别扭,你得用特殊工具链,还要处理 CGO。第二,SetWindowsHookEx 的全局钩子会注入到系统里所有满足条件的进程,影响面太大,动不动就让杀软报警,而且钩子会拖慢系统消息处理。第三,钩子方案是“被动等消息”。如果密码框里的文本在你挂上钩子之前就已经存在了,你需要先触发一次重绘或者切换焦点才会捕获到内容,操作起来不够直接。我需要的是一次“主动查询”而不是“被动窃听”,所以 SetWindowsHookEx 第一步就被排除了。

2.3 Shellcode 注入的优势与代价

那么剩下最直接的路就是:主动把一小段代码投放到目标进程里,让它在目标进程内主动读取控件文本,然后把明文写到一块我知道地址的缓冲区里,我再从外部把缓冲区内容读回来。

对比一下常见方案:

方案代码量跨进程成功率杀软敏感度是否依赖编译器
SendMessage 直读极少低(密码框失败)低无
SetWindowsHookEx 钩子中高高需要 DLL
注入 DLL多高高需要 DLL
Shellcode 注入中高中高无,嵌入字节数组

Shellcode 注入的好处是:不需要 DLL 文件落盘,代码以裸机器码形式存在,直接写进目标进程内存;可以主动执行、主动返回结果,整个流程完全由我控制。代价也很明显:手写/生成 shellcode 需要一点汇编功底;远程线程创建是杀软重点盯防的行为,容易被拦。但对这种一次性的小工具来说,这个成本可以接受。

2.4 整体工作流程

在动手写代码之前,我在脑子里把整个流程排成了一串固定步骤:

  1. 枚举当前桌面所有顶层窗口,根据标题或进程名锁定目标窗口。
  2. 在目标窗口内枚举所有子控件,找到带 ES_PASSWORD 样式的 Edit 控件句柄。
  3. 用 OpenProcess 打开目标进程,拿到进程句柄。
  4. 在目标进程里用 VirtualAllocEx 分配一块内存,一部分放参数结构体,一部分放 shellcode。
  5. 用 WriteProcessMemory 把 shellcode 和参数写进去。
  6. 用 CreateRemoteThread 在目标进程里创建一个线程,让 shellcode 从参数区取出控件句柄,调用 SendMessageW 读取真实文本,写到输出缓冲区。
  7. 在外部用 ReadProcessMemory 把输出缓冲区内容读回来。
  8. 清理分配的内存和句柄。

这个流程实际上就是把“在一个进程里读控件文本”这件事,拆成了“外部负责准备、内部负责执行、结果再传回来”的三段式。每个步骤之间靠进程句柄、内存地址和线程句柄串联。下面一章我会给出每一步的具体代码和关键参数。

3. 核心实现:一步一步把密码捞出来

3.1 用纯 Go 声明 Win32 API,不依赖 CGO

纯 Go 调用 Win32 API,主要通过 syscall.NewLazyDLL 加载系统 DLL,再用 NewProc 获取函数地址。我建议直接用 golang.org/x/sys/windows 包,它对常用的句柄类型、进程权限常量、Unicode 转换都有封装,比裸 syscall 舒服不少。但有一部分 API 没有现成封装,比如 EnumWindows 的回调、GetWindowLongPtrW 这类,就需要自己声明。

我这里直接列出项目里用到的基础声明:

var ( user32 = syscall.NewLazyDLL("user32.dll") kernel32 = syscall.NewLazyDLL("kernel32.dll") procEnumWindows = user32.NewProc("EnumWindows") procEnumChildWindows = user32.NewProc("EnumChildWindows") procGetWindowThreadProcessId = user32.NewProc("GetWindowThreadProcessId") procGetClassNameW = user32.NewProc("GetClassNameW") procGetWindowLongPtrW = user32.NewProc("GetWindowLongPtrW") procGetWindowTextW = user32.NewProc("GetWindowTextW") // 备用 procOpenProcess = kernel32.NewProc("OpenProcess") procVirtualAllocEx = kernel32.NewProc("VirtualAllocEx") procWriteProcessMemory = kernel32.NewProc("WriteProcessMemory") procCreateRemoteThread = kernel32.NewProc("CreateRemoteThread") procWaitForSingleObject = kernel32.NewProc("WaitForSingleObject") procReadProcessMemory = kernel32.NewProc("ReadProcessMemory") procCloseHandle = kernel32.NewProc("CloseHandle") )

注意一个细节:64 位 Windows 上一定要用 GetWindowLongPtrW,而不是 GetWindowLongW。原因很简单,64 位系统里窗口句柄、样式值都是 64 位宽度,GetWindowLongW 在 64 位进程里无法完整返回数据,会导致你拿到的样式值缺失高位,判断 ES_PASSWORD 时可能出错。Go 的 uintptr 本身就是 64 位,所以直接用 NewProc 声明,不用特别处理。

调用方式也不复杂。比如 EnumWindows,它需要一个回调函数指针。Go 里可以用 syscall.NewCallback 把一个 Go 函数转换成 Windows 能识别的回调地址:

func enumWindowsCallback(hwnd syscall.Handle, lparam uintptr) uintptr { // 在这里处理每个顶层窗口 return 1 // 返回 1 继续枚举 } func listTopWindows() { procEnumWindows.Call( syscall.NewCallback(enumWindowsCallback), 0, ) }

这一个细节卡了我很久:Windows 的枚举回调函数返回值用 BOOL,0 表示停止枚举,非 0 表示继续。在 Go 里如果你直接返回 false 或者 0,反而不对,因为 NewCallback 生成的函数会把 Go 的 bool 转换成 0/1,需要特别注意。

3.2 筛选密码框控件

拿到目标窗口的句柄后,下一步是枚举子控件,找出密码框。判断依据很简单:控件类名是 “Edit”,同时样式里包含 ES_PASSWORD(0x20)。

先按类名过滤,再用 GetWindowLongPtrW 拿样式:

func findPasswordEdits(parent uintptr) []uintptr { var results []uintptr callback := syscall.NewCallback(func(hwnd uintptr, lparam uintptr) uintptr { var className [256]uint16 procGetClassNameW.Call(hwnd, uintptr(unsafe.Pointer(&className[0])), 256) if syscall.UTF16ToString(className[:]) != "Edit" { return 1 // 继续枚举 } style, _, _ := procGetWindowLongPtrW.Call(hwnd, GWL_STYLE) if style&0x20 != 0 { // ES_PASSWORD results = append(results, hwnd) } return 1 }) procEnumChildWindows.Call(parent, callback, 0) return results }

这一步看着简单,但有一个隐藏问题:不能只依赖样式。有些自定义控件不是标准 Edit 类,它们可能自己实现了密码显示,却没有设置 ES_PASSWORD 样式。这种情况下,你需要用另一种思路:先取当前控件的显示文本(比如那串星号),再用 SendMessage 发 WM_GETTEXT,如果两者不一致,说明可能是自定义密码框。不过这个项目定位是“通用工具”,能处理标准 Edit 就已经解决了 90% 的场景,自定义控件属于另一个话题,后面有机会再写。

3.3 把 shellcode 送进目标进程

选定目标控件后,我要做三件事:拿到目标进程句柄、分配内存、写入代码和数据。这一连串操作就是经典的“CreateRemoteThread 注入三步曲”。

先拿进程句柄:

var pid uint32 procGetWindowThreadProcessId.Call(hwnd, uintptr(unsafe.Pointer(&pid))) hProcess, _, err := procOpenProcess.Call( PROCESS_ALL_ACCESS, 0, uintptr(pid), ) if hProcess == 0 { // 失败常见原因:权限不足、目标进程受保护 }

这里注意,PROCESS_ALL_ACCESS 可能被现代 Windows 降权,因为部分进程带保护(PPL)。如果是自己做实验,建议先跑目标程序,别拿系统关键进程开刀。

然后分配内存并写入:

procVirtualAllocEx.Call( hProcess, 0, size, MEM_RESERVE|MEM_COMMIT, PAGE_EXECUTE_READWRITE, )

这行代码里有三个魔鬼细节。

第一个是内存属性 PAGE_EXECUTE_READWRITE。它同时给了可执行和可写权限,这对 shellcode 注入来说是“刚需”,但也是杀软最敏感的信号。正常程序很少会在一块内存里写入代码再执行。如果你只想减少特征,可以把 shellcode 和数据分块:代码区用 PAGE_EXECUTE_READ,参数区用 PAGE_READWRITE。我这个工具图省事,直接合一块了。

第二个是 MEM_RESERVE|MEM_COMMIT 组合。VirtualAllocEx 分配时要先预留地址空间再提交物理页面,Windows 上一般是两个标志一块用。熟练以后这块没人会写错,但新手经常只传一个 MEM_COMMIT,结果就是返回的地址能用,但不可读/不可写/不可执行,后续 WriteProcessMemory 直接失败。

第三个是地址对齐。VirtualAllocEx 返回的地址页对齐,写入时不用做太长指令的对齐处理。但 WriteProcessMemory 按字节写,如果 shellcode 里引用了自身某个位置的绝对地址,必须在生成时保证偏移正确,否则一执行就崩。

写入过程很简单:

var written uintptr procWriteProcessMemory.Call( hProcess, baseAddr, uintptr(unsafe.Pointer(&shellcodeBytes[0])), uintptr(len(shellcodeBytes)), uintptr(unsafe.Pointer(&written)), )

写完代码后,还要把参数结构体写到同一块内存的后面。参数结构体里包含控件句柄、SendMessageW 地址、输出缓冲区地址、缓冲区大小。SendMessageW 地址用这个方式拿:

var modUser32 = syscall.NewLazyDLL("user32.dll") var procSendMessageW = modUser32.NewProc("SendMessageW")

在外部进程拿到的函数地址,能不能直接在目标进程里用?这里有一个 Windows 平台的特色:系统 DLL 在同一个会话内,加载基址基本一致。因为 user32.dll 在系统初始化时就被映射到每个 GUI 进程,ASLR 虽然随机化了基址,但同一份 DLL 文件在所有进程里通常映射到同一个虚拟地址。实测下来,标准 64 位进程之间,这个地址一致的概率极高。如果遇到不一致,就需要在 shellcode 里解析目标进程的 PEB 来定位 user32.dll,复杂度会明显上升,这个放到后面章节说。

3.4 创建远程线程并取回明文

代码和数据都就位,接下来就是“点一把火”:

hThread, _, _ := procCreateRemoteThread.Call( hProcess, 0, 0, codeAddr, argsAddr, 0, 0, ) procWaitForSingleObject.Call(hThread, INFINITE)

CreateRemoteThread 的参数含义分别是:进程句柄、安全属性、栈大小、起始地址、参数区域地址、创建标志、线程 ID 输出。这里线程栈大小传 0,表示用系统默认值。注意一点:shellcode 很简单,没有复杂逻辑,所以栈够用。如果你的 shellcode 很大、调用了很多 API,建议把栈大小设置为 8KB 或者 16KB,避免线程栈溢出导致目标进程崩溃。

线程执行完后,外部用 ReadProcessMemory 把输出缓冲区读回来。

var outBuf [512]uint16 var read uintptr procReadProcessMemory.Call( hProcess, outputAddr, uintptr(unsafe.Pointer(&outBuf[0])), unsafe.Sizeof(outBuf), uintptr(unsafe.Pointer(&read)), ) text := syscall.UTF16ToString(outBuf[:])

我要特意说明:输出缓冲区大小不要只给 64 字节。有些密码框内容很短,但有些软件会在输入框里放一长串“记住的凭据”,缓冲区给 512 个 wchar 比较稳妥。如果读出来是空串但框里明显有星号,大概率是 SendMessageW 的第三个参数传错了——WM_GETTEXT 的 wParam 是字符数,lParam 是目标缓冲区地址,两个参数顺序写反会直接拿不到内容。

3.5 shellcode 具体长什么样

整个注入的核心就是 shellcode。我这版用的是 64 位汇编,逻辑非常简单:从参数结构体里取出 hwnd、SendMessageW 函数地址、输出缓冲区地址、长度,然后把它们按 x64 调用约定传给 SendMessageW。

参数结构体定义:

typedef struct { HWND hwnd; DWORD msg; // WM_GETTEXT = 0x000D WPARAM wparam; // 缓冲区字符数 LPARAM lparam; // 输出缓冲区地址 FARPROC pfnSendMessageW; } ARGS;

nasm 源码:

section .text global shellcode shellcode: ; rcx = ARGS* args mov r10, rcx ; 取出各参数字段 mov rcx, [r10 + 0x00] ; hwnd mov rdx, [r10 + 0x08] ; msg = WM_GETTEXT mov r8, [r10 + 0x10] ; wparam = 缓冲区长度 mov r9, [r10 + 0x18] ; lparam = 缓冲区地址 mov r11, [r10 + 0x20] ; pfnSendMessageW sub rsp, 0x28 ; 预留影子空间,满足 x64 调用约定 call r11 add rsp, 0x28 ret

这段汇编编译成二进制后,就是一段不到 40 字节的 shellcode。注意:这里的 shellcode 只做了一件事,就是“以目标进程身份调用 SendMessageW 读取控件文本”。它不下载、不联网、不弹窗,行为非常干净。

有人问:为什么不用 Go 直接生成这段机器码?因为 Go 标准库没提供内联汇编,所以我在工程里用 nasm 生成一个 .bin 文件,再用 go:embed 把它嵌进二进制。这是比较体面的流程,举例如下:

nasm -f bin shellcode_x64.asm -o shellcode_x64.bin

然后在 Go 代码里:

import _ "embed" //go:embed shellcode_x64.bin var shellcodeX64 []byte

如果你不想引入 nasm,也可以用开源库里的预编译 shellcode 字节数组,但我不建议直接抄网上的字节数组,因为你不知道它作者在里面塞了什么额外代码。自己用汇编源码生成,至少能确保每一字节都是可解释的。

4. 实操中的坑:权限、位数、会话、杀软

4.1 权限与 UIPI:为什么很多窗口“读不到”

写完第一版,我高高兴兴地跑起来,结果发现大部分目标窗口的密码框根本读不出来,OpenProcess 返回 0。排查了半天,发现是权限问题。

Windows Vista 之后引入了 UIPI(User Interface Privilege Isolation,用户界面特权隔离)。简单说,高权限进程的 UI 消息不允许被低权限进程干扰。如果你是以普通用户身份运行这个工具,而目标窗口属于管理员权限启动的程序,那么就算你拿到了窗口句柄,SendMessage 也会被系统悄悄丢掉,甚至 OpenProcess 都打不开。这是系统设计如此,不是 API 用错了。

解决办法是:把自己这个查看器也用管理员权限启动。右键“以管理员身份运行”,或者在编译时嵌入 manifest 让程序始终请求管理员权限。我实际测试后发现,只要两边权限一致,UIPI 这关就过了。

另外还要注意:如果你面对的进程是 SYSTEM 权限(比如某些服务弹出的登录框),那你管理员权限也不够,因为服务进程的窗口通常不在当前用户桌面。跨会话读取属于另一个层次的问题,下面单独说。

4.2 32 位和 64 位:注入失败的头号原因

第二个大坑是位数不匹配。我把工具编译成了 64 位版本,然后去读一个 32 位的老软件窗口。OpenProcess 成功,VirtualAllocEx 成功,WriteProcessMemory 成功,CreateRemoteThread 也成功,但 shellcode 执行后目标进程直接崩了。为什么?

因为 32 位进程和 64 位进程不是同一个“世界”。32 位进程运行在 WOW64 模拟层,它的内存布局、系统调用约定、DLL 加载路径都跟 64 位进程不同。我注入的这段 64 位 shellcode 进入 32 位进程后,CPU 直接按 64 位模式解释指令,目标进程根本没有 64 位执行环境,栈和寄存器都被打乱了,不崩才怪。

正确的做法是准备两套 shellcode:一套 x64、一套 x86,然后根据目标进程位数选择注入哪一套。怎么判断目标进程位数?Windows 提供了 IsWow64Process2,专门用来判断进程是原生 64 位还是 WOW64 的 32 位。或者更粗暴但有效的方法:把查看器同时编译成 386 和 amd64 两个版本,跑的时候根据目标进程架构选对应版本。我当时就是把 32 位和 64 位两个 exe 都编出来了,Windows 上文件也就几十 KB,没负担。

4.3 会话隔离与窗口站:服务里的窗口是另一回事

再高级一点的问题是会话隔离。RDP 远程桌面、服务进程、控制台会话都有各自的 session。同一个用户开着两个 RDP 会话,你在这个会话里看不到另一个会话的窗口句柄,更别说读取控件内容。session 之间被系统隔离得很干净。

解决方案也不难:在读取之前先检查目标线程所在的会话 ID,用 ProcessIdToSessionId 拿到 PID 对应的会话号,再跟当前进程的会话号对比。不是同一个会话就直接跳过。这条规则特别重要,否则工具一旦拿到其他会话里的窗口句柄,读出来的可能不是你想要的内容,或者触发权限错误。

注意还有窗口站(WindowStation)和桌面(Desktop)的概念。普通用户桌面是 winsta0\default,但有些程序会创建独立的窗口站(比如服务弹出的交互式消息窗口)。不同窗口站之间的窗口句柄不能直接互操作。一个小工具没必要把所有场景都兼容,声明“仅支持当前用户默认桌面上的标准窗口”就够了,这个范围能覆盖 95% 的实际需求。

4.4 杀软与行为检测:这条路上最大的拦路虎

我做完以后第一个实验对象是我自己写的一个 WinForms 测试程序,很顺利。然后我试着去读某个聊天软件记住的密码框,Windows Defender 直接火了,杀毒弹窗弹出,提示检测到 HackTool:Win32/Keygen 之类的威胁。

说实话,这不是误报。OpenProcess 打开其他进程 + VirtualAllocEx 分配可执行内存 + WriteProcessMemory 写代码 + CreateRemoteThread 远程启动线程,这四件事连在一起,就是教科书级的进程注入特征。任何正经杀毒软件都会拦。即便你把进程名改成“System Helper”也没用,因为行为特征太明显了。

要绕杀软需要非常小心,而且我明确不建议普通用户去对抗杀软,那是一条灰色地带。如果你真的需要这个工具,最靠谱的做法是:

  • 在完全离线的实验环境里使用;
  • 用自己写的小程序作为目标进程;
  • 或者临时把 Defender 的实时保护关掉,用完再开。

我自己的实践是:在虚拟机里测试,目标是虚拟机里的测试程序,宿主机的杀软完全不受影响。这个工具满足学习需求是够了,但如果你要拿去处理公司电脑里的生产数据,一定要先走合规流程。

4.5 其他零碎的坑:编码、句柄泄露与超时

除了上面几个大坑,还有一些小问题值得记录一下。

第一,编码。Win32 的 WM_GETTEXT 读取的是 UTF-16(其实是系统代码页相关的 Unicode,现代 Windows 就是 UTF-16),在 Go 里要用 syscall.UTF16ToString 转换,直接当 byte 数组处理会乱码。第二个:句柄泄露。OpenProcess、CreateRemoteThread 拿到的句柄用完一定要 CloseHandle。别小看这个,每次读一个密码框就泄露两个句柄,跑一个自动化遍历任务,几百个窗口下来进程句柄表就满了,工具自己会先挂。第三,远程线程要加超时。有些目标进程的线程调度异常,远程线程创建后迟迟不执行,WaitForSingleObject 如果无限等待,工具会卡死。给个 3 到 5 秒超时比较合适。

5. 使用场景、边界与合规提醒

5.1 哪些场景真的能用到它

聊完实现,再回到这个工具的实际意义。首先就是密码找回。很多企业老旧系统没有“忘记密码”功能,找回密码要走表单流程,一等就是半天。如果你在合法岗位、合法设备上,需要从本机已保存的登录窗口里找回自己账号的密码,这类工具确实能省很多事。

其次是 UI 自动化测试。窗口枚举、控件查找、跨进程读取控件内容,这套技术栈可以直接用到自动化测试框架里。你可以写一个 Go 程序,枚举目标软件的所有窗口和控件,检查密码框是否真的把用户输入隐藏了,这在安全测试里叫“控件信息安全测试”。同样一套代码,换个角度就是找漏洞的工具:如果哪个软件把密码明文直接塞进 Edit 控件还不做保护,你就能快速发现。

然后是教学研究。进程注入、Win32 编程、shellcode 编写,这几个知识点单独看都很抽象,合在一起做一个工具,反而容易理解。我在写这个项目时最大的收获就是:彻底搞懂了 x64 调用约定、Windows 句柄权限模型、进程间内存隔离这三件事,比我翻十篇文档都有用。

5.2 这套技术还能延展到什么方向

如果你觉得“星号密码查看器”太窄,你可以在这个框架上继续扩展:

  • 批量窗口审计:遍历所有顶层窗口,列出所有密码框的位置、窗口文本、所属进程,生成报告;
  • 控件信息提取:不限于密码框,把文本框、下拉框、按钮的文本全部读出,做 UI 自动化数据源;
  • 内存搜索:在目标进程里搜特定字符串,可以用于调试、分析某些程序的硬编码配置;
  • 钩子监控:把同样的注入思路改成安装本地消息钩子,分析控件的消息序列,理解某个 UI 组件的工作机制;
  • 兼容性测试:检查同一套注入代码在不同 Windows 版本上的行为差异。

每个方向背后都要引入更多 API、更复杂的逻辑,但对开发者来说都是实打实的能力成长。

5.3 红线:别让它变成偷密码的工具

我必须把红线说清楚。这个工具能“读取密码框明文”,在能力边界上和恶意软件抓取密码的行为是有重叠的。区别在于用途、场景和授权。我的态度是:

  • 只能读取你自己电脑上、你自己登录会话中的窗口;
  • 只能把技术用在你拥有权限的目标上;
  • 不要读取同事、朋友的窗口,不要抓取他人输入的内容;
  • 不要把它用于绕过任何系统的认证流程,也不要去破解别人的账户凭证;
  • 不要试图对抗杀软把它做成“免杀”。

你用它来学习、自查、找回自己遗忘的密码,这是没问题的;但你把它拿去偷别人的账号密码,那它就是实实在在的恶意外挂。技术是中性的,使用技术的人要有边界感。读者里如果有同学把这篇文章当教程,希望你学的不是“怎么偷密码”,而是“Windows 底层是怎么运作的”“跨进程数据交换有什么安全设计”“一个看似简单的读取操作背后有多少层保护”。

6. 我的实践体会

这个工具我从立项到跑通,前后花了一个周末。第一个版本用的方案是外部 SendMessage,结果是只有自己创建的窗口能读,第三方窗口全失败,给了我一个很深刻的“安全机制教育”。后来改走 VirtualAllocEx + CreateRemoteThread 注入,第一次成功读到一个真实第三方程序密码框的时候,还是蛮有成就感的。

但我后来在实际工作中反而很少用它了。原因很简单:现在很多新版软件不用标准 Edit 控件,而是自绘控件、网页控件(CEF/WebView)、Electron 应用。对这类程序,我这个 Win32 注入思路完全不奏效。它们要么把密码框画在自绘逻辑里,要么根本就是网页里的 input 元素。遇到这类程序,更实用的方案是使用 Windows UI Automation 框架,通过 ValuePattern 读取密码框内容,或者直接用 WebDriver 协议去抓 DOM 里的 value。技术永远在变化,早些年的标准 Win32 接口逐渐被新框架替代,阅读密码框这种事情,也从一个底层 API 问题变成了一个“你到底面对什么 UI 框架”的侦查问题。

最后分享一个小经验:如果你想在目标机器上快速验证一个窗口是不是密码框,可以先用 Spy++ 或者 WinSpy 这类工具看一眼控件样式。只要样式里有 ES_PASSWORD,再继续上注入流程,效率会高很多。盲扫整个窗口树找密码框虽然能跑,但你会看到一堆其他控件,浪费时间还有可能误报。先侦查,再动手,这是所有 Windows GUI 工具开发通用的原则。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询