
做 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多高高需要 DLLShellcode 注入中高中高无嵌入字节数组Shellcode 注入的好处是不需要 DLL 文件落盘代码以裸机器码形式存在直接写进目标进程内存可以主动执行、主动返回结果整个流程完全由我控制。代价也很明显手写/生成 shellcode 需要一点汇编功底远程线程创建是杀软重点盯防的行为容易被拦。但对这种一次性的小工具来说这个成本可以接受。2.4 整体工作流程在动手写代码之前我在脑子里把整个流程排成了一串固定步骤枚举当前桌面所有顶层窗口根据标题或进程名锁定目标窗口。在目标窗口内枚举所有子控件找到带 ES_PASSWORD 样式的 Edit 控件句柄。用 OpenProcess 打开目标进程拿到进程句柄。在目标进程里用 VirtualAllocEx 分配一块内存一部分放参数结构体一部分放 shellcode。用 WriteProcessMemory 把 shellcode 和参数写进去。用 CreateRemoteThread 在目标进程里创建一个线程让 shellcode 从参数区取出控件句柄调用 SendMessageW 读取真实文本写到输出缓冲区。在外部用 ReadProcessMemory 把输出缓冲区内容读回来。清理分配的内存和句柄。这个流程实际上就是把“在一个进程里读控件文本”这件事拆成了“外部负责准备、内部负责执行、结果再传回来”的三段式。每个步骤之间靠进程句柄、内存地址和线程句柄串联。下面一章我会给出每一步的具体代码和关键参数。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 的枚举回调函数返回值用 BOOL0 表示停止枚举非 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 style0x20 ! 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 之后引入了 UIPIUser 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 工具开发通用的原则。