
写这一讲之前我犹豫了一会儿因为“拾取密码框中的密码”这八个字听起来确实有点像要教人干坏事。但拆开看它其实是一个非常纯粹的Windows编程问题一个带ES_PASSWORD样式的Edit控件把用户敲进去的字符用星号或圆点显示出来那底层明文到底藏在哪里在什么条件下能被同一个桌面上的其他进程合法取回这背后涉及的窗口消息、控件的窗口过程、样式的运行时切换、DLL注入全是汇编学习者绕不开的知识点。这篇文章的代码本质上就是一个“密码查看器”我先把适用范围说清楚最正当的用法是给自己写的登录对话框做UI自动化测试、排查输入框的内存清理问题或者做安全审计时评估第三方程序的内存暴露面。未经授权去读取别人程序的密码框内容那超出了技术讨论的合法边界这点我默认大家都懂。下面我们把原理拆到底再一步步写出可运行的汇编程序。1. 先从一句大实话开始星号背后从来不是加密1.1 ES_PASSWORD样式到底改了些什么Windows的密码框不是什么特殊的“密码控件”它就是一个普普通通的Edit控件只不过创建时多了一个窗口样式ES_PASSWORD。这个样式的作用范围非常窄基本只影响两件事绘图和剪贴板相关操作。当Edit控件带着ES_PASSWORD样式重绘时窗口过程不会把缓冲区里的真实字符画出来而是对每个字符都用同一个密码字符替代默认是星号或者圆点。你还可以用EM_SETPASSWORDCHAR消息把替代字符改成任意你想用的字符。也就是说用户输入Pssw0rd屏幕上出现的是********但控件内部保存的字符串仍然是完整的Pssw0rd。打个比方这就好比你在纸上写了身份证号然后拿一张便签纸盖住便签纸上只画了几个黑点。黑点不是把身份证号加密了它只是挡住了视线。你掀开便签纸数字还在同一个位置。ES_PASSWORD就是那张便签纸。不少初学者会把“星号显示”误当成“已经加密存储”这个误解会带来两个方向的错误认识一方面写软件的人以为密码框天然安全不用清理内存另一方面想做安全测试的人以为必须逆向某种加密算法才能拿回密码。其实两边都搞错了密码就在那儿关键是你能不能合法地、跨进程地够到它。1.2 内部缓冲区才是明文的真正归宿Edit控件在内部维护一个文本缓冲区WM_SETTEXT、WM_GETTEXT、EM_GETLINE这些消息全部围绕这个缓冲区做读写。ES_PASSWORD样式不影响缓冲区的存储格式明文按原样存放。想验证这一点特别简单我常在演示课上用两个消息先往一个密码框发WM_SETTEXT把内容设成一段明文比如ABC123。此时屏幕上显示的是三个掩码字符但随后立刻发送WM_GETTEXT出来在老的 Windows 系统上拿到的往往就是ABC123。同一个控件、同一个缓冲区只是画法变了存储没变。另外还有一个非常直观的证据给一个设置了ES_PASSWORD的Edit控件设置不同的EM_SETPASSWORDCHAR比如改成#界面上的掩码字符会立即从星号变成#。如果缓冲区里存的是已经“加密”后的数据或者直接存星号那它怎么知道你把它改成#之后该画什么说明掩码字符只是在绘制瞬间生成的替代品原始文本一直原封不动地躺在内部缓冲区里。1.3 GetWindowText这类花招在密码框上为何经常失灵新手尝试“取密码”第一反应往往是调用GetWindowText。这个 API 的签名和用法太简单了传进去一个窗口句柄它把文本拷贝到缓冲区。问题在于GetWindowText在跨进程场景下对密码框的行为很不可靠经常返回一串星号甚至返回空串。原因在于GetWindowText本质上是去给目标窗口发送一个WM_GETTEXT消息。为了让消息能够跨进程传送Windows 系统层会对这类系统消息做封送marshaling。在较新的系统上系统收到针对ES_PASSWORD控件的跨进程序WM_GETTEXT会主动用掩码字符填充缓冲区而不是返回内部真实文本。这是微软有意加固的结果就是为了防住这种“一行代码查密码”的套路。所以结论很清楚想靠GetWindowText直接跨进程拾取密码框明文在今天的大部分系统上都是此路不通。但这并不代表没有路只是需要绕开那层检查或者直接在目标进程内部动手。2. 老派但有效用 SendMessage(WM_GETTEXT) 直接向控件要明文2.1 消息封送与控件窗口过程为什么跨进程也要得到答案在深入代码之前先理解一个关键背景SendMessage发送消息时如果目标窗口属于另一个进程系统会先把消息参数封送进目标进程目标进程里的控件窗口过程处理完再把结果封送回调用进程。整个过程对调用者来说是同步的看起来就像在本地调用一样。在 Windows 2000/XP 年代ES_PASSWORD样式下Edit控件的窗口过程对WM_GETTEXT的处理相对“老实”封送层也没有额外的拦截所以通过FindWindow找到窗口、再SendMessage发WM_GETTEXT跨进程取密码文本的成功率非常高。很多老牌的“密码查看器”用的就是这条路。它之所以比GetWindowText好使GetWindowText走的是系统封装过的内部消息路径而直接SendMessage(WM_GETTEXT)在某些旧系统上绕开了部分保护逻辑直接触达控件的普通窗口过程。注意这里的“好用”是不是普遍规律取决于操作系统版本和当时的加固程度。所以我把这一章定位为“原理解析的起点”它帮助你理解消息是怎么一路送到控件内部的而现在的环境里你多半会碰到下面的代码在某些系统上依然有效、在某些系统上只能拿到掩码的情况。2.2 MASM32可运行示例找窗口、找控件、取内容下面这段代码我尽量写得短但已经是一份完整的 MASM32 程序。它做的事情只有三件事按窗口标题找到主窗口在主窗口里遍历子控件找到Edit然后向Edit控件发送WM_GETTEXT取文本。.386 .model flat, stdcall option casemap :none include \masm32\include\windows.inc include \masm32\include\user32.inc include \masm32\include\kernel32.inc includelib \masm32\lib\user32.lib includelib \masm32\lib\kernel32.lib .data szWinTitle db 模拟登录窗口, 0 ; 改成你自己的测试窗口标题 szEditClass db Edit, 0 ; 标准Edit控件类名 szNotFound db 找不到目标窗口/控件, 0 szFmt db 句柄: %08X 字符数: %d, 0 szResult db 256 dup (0) szMsgTitle db 64 dup (0) .data? hRoot dd ? hEdit dd ? nLen dd ? .code start: ; 1. 按标题找主窗口 invoke FindWindow, NULL, addr szWinTitle mov hRoot, eax .IF eax NULL invoke MessageBox, NULL, addr szNotFound, NULL, MB_OK jmp quit .ENDIF ; 2. 在主窗口下找第一个Edit子控件 invoke FindWindowEx, hRoot, NULL, addr szEditClass, NULL mov hEdit, eax .IF eax NULL invoke MessageBox, NULL, addr szNotFound, NULL, MB_OK jmp quit .ENDIF ; 3. 向Edit控件发送WM_GETTEXT要求写入最多256个字符 invoke SendMessage, hEdit, WM_GETTEXT, 256, addr szResult mov nLen, eax ; 4. 弹出结果 invoke wsprintf, addr szMsgTitle, addr szFmt, hEdit, nLen invoke MessageBox, hRoot, addr szResult, addr szMsgTitle, MB_OK quit: invoke ExitProcess, 0 end start逐段说一下FindWindow的第一个参数是类名传NULL表示只按窗口标题匹配。找到主窗口句柄后FindWindowEx在主窗口的子窗口链里寻找类名为Edit的第一个控件。如果你的密码框在对话框模板里的 ID 是固定的更稳妥的做法是用GetDlgItem直接按 ID 取句柄可以避免同时存在多个Edit时取错。关键在第三步SendMessage的参数分别是目标控件句柄、消息号WM_GETTEXT、缓冲区最大字符数、缓冲区指针。返回值EAX存放实际复制了多少个字符。对 ANSI 版的SendMessage对应的是 ANSI 字符串缓冲区大小按字节算如果你在 Unicode 程序里混用长度单位会出问题细节稍后讲。2.3 在老系统与新系统上的行为差异这段代码在 Windows 2000/XP 上对普通Edit密码框命中率极高但拿到现在的 Windows 10/11 上结果就有变数了有些系统上依然能取到明文有些系统上系统层把消息拦截了返回给缓冲区的是掩码字符取出来是一串*。这不是代码写错而是系统加固策略在演进。实战中的经验是把这种“直接跨进程发消息”的方式当作理解消息封送机制的入口就好真正要稳定取到明文请看下一章的注入方案。另外还有一点要提醒如果目标程序用的是自绘控件、第三方控件库或者 WPF 那种完全走自己的渲染管线的控件那么类名根本不是标准EditSendMessage(WM_GETTEXT)也未必有对应实现。判断控件类型永远比写代码更优先。还有一个隐藏坑缓冲区太小会导致取回的文本被截断WM_GETTEXT不会告诉你字符串实际有多长它只按你给的wParam尽量拷贝。稳妥做法是先发WM_GETTEXTLENGTH拿到长度再分配足够大的缓冲区。我在这段示例里直接用了 256 字节演示够用真实工具别偷懒。3. 现代系统上的组合拳DLL注入加临时摘掉ES_PASSWORD3.1 为什么单纯发消息越来越不靠谱跨进程发消息的不稳定性来自两边一边是系统封送层对WM_GETTEXT在密码框上的特殊处理一边是高版本 Windows 的 UIPI用户界面特权隔离会拦截低信任级别进程向高信任级别进程发送窗口消息。你没法保证调用者跟目标程序处于同一个权限等级更没法保证消息封送层不掺和。这时候最彻底的思路就一条不跟系统层猜拳了想办法让代码跑进目标进程在控件身边做手脚——也就是 DLL 注入。注入成功后你不再需要跨进程封送WM_GETTEXT因为代码就在目标进程里调用SendMessage是进程内调用。同时你还可以修改控件样式直接绕开颠倒黑白的那个样式检查。3.2 SetWindowsHookEx注入DLL的链路注入 DLL 的方法有很多对窗口程序来说最简单粗暴的方式是SetWindowsHookEx安装全局钩子。钩子本身通常不承担什么复杂逻辑它的真正作用是让系统把我们的 DLL 映射到目标进程的地址空间。一旦 DLL 进去了想干的事再慢慢干。基本链路是这样的编写一个 DLL里面导出两个函数SetHook和HookProc。SetHook调用SetWindowsHookEx钩子类型可以选WH_CALLWNDPROC这样目标进程每次收到SendMessage过来的消息时HookProc都会被回调。宿主程序加载 DLL调用SetHook把钩子挂上。当HookProc发现消息的hwnd正是我们要找的密码框句柄时记住这个句柄、安装子类窗口过程然后立刻卸载钩子。之所以要用WH_CALLWNDPROC而不是WH_GETMESSAGE是因为SendMessage同步发送的消息不经过消息队列WH_GETMESSAGE钩子根本看不见。WH_CALLWNDPROC则是在系统调用目标窗口过程之前触发能覆盖这类直接发送的消息。钩子装完、DLL 进目标进程后最重要的一步是卸载全局钩子。全局钩子不卸载会让所有后续加载的每个 GUI 进程都把 DLL 拉进去轻则拖慢系统重则引发各种诡异崩溃。我当年第一次写这个示例时忘了卸载钩子结果打开记事本都卡了一两秒后来开着任务管理器逐个排查才发现是钩子残留在搞鬼。3.3 摘掉样式取文本的三个关键操作与重绘坑DLL 进入目标进程之后最经典的做法不是穷举各种消息而是三连招第一步把GWL_STYLE里的ES_PASSWORD位摘掉。第二步给控件发送WM_GETTEXT此时控件认为自己不是密码框老老实实返回明文。第三步把样式恢复回去避免界面行为被改变。对应汇编片段如下我在 32 位系统上通常直接用SetWindowLong64 位上换SetWindowLongPtr两者的参数和语义一致只是指针宽度不同。; 已注入到目标进程hEdit 是密码框控件句柄 invoke GetWindowLong, hEdit, GWL_STYLE and eax, NOT ES_PASSWORD ; 摘下密码样式 invoke SetWindowLong, hEdit, GWL_STYLE, eax invoke SendMessage, hEdit, WM_GETTEXT, 512, addr g_szBuffer mov g_dwLen, eax ; 明文已经到 g_szBuffer invoke GetWindowLong, hEdit, GWL_STYLE or eax, ES_PASSWORD ; 恢复密码样式 invoke SetWindowLong, hEdit, GWL_STYLE, eax三个关键点缺一不可。第一个关键点在于恢复样式的顺序。必须在取完文本之后立刻恢复不能先恢复再取文本也不能取完不恢复。恢复得晚目标程序的密码框就长时间处于无样式状态用户随便敲一下都能看到明文这是绝对不可接受的。第二个关键点是重绘坑。SetWindowLong修改GWL_STYLE之后控件会收到重绘消息如果窗口正好在屏幕上可见那摘除ES_PASSWORD的一瞬间原先的掩码字符会直接变成明文显示出来。在真实演示里我吃过一次大亏测试窗口停在桌面中间代码一跑密码明晃晃地在屏幕上闪了一秒截图都拍下来了。后来学乖了要么先把目标窗口ShowWindow(SW_HIDE)要么等窗口最小化时再操作要么干脆把操作放在窗口不在前台的时候。第三个关键点是子类化思路的扩展。如果不满足于“被抓一次”而是想长期监视密码框那就用SetWindowLongPtr(GWL_WNDPROC)给目标控件安一个子类窗口过程把所有发给它的WM_GETTEXT都拦截下来在子类过程中临时摘样式、调原窗口过程拿明文、恢复样式、再把结果返回给调用者。这样每次有人向这个控件查询文本你都能得到一份明文副本而控件自己毫无感觉。; 子类窗口过程的核心分支伪汇编 NewWndProc: cmp uMsg, WM_GETTEXT jne pass_to_old ; 摘掉ES_PASSWORD invoke GetWindowLong, hWnd, GWL_STYLE and eax, NOT ES_PASSWORD invoke SetWindowLong, hWnd, GWL_STYLE, eax ; 调用原窗口过程拿到明文 push lParam push wParam push WM_GETTEXT push hWnd call OldWndProc ; 恢复ES_PASSWORD invoke GetWindowLong, hWnd, GWL_STYLE or eax, ES_PASSWORD invoke SetWindowLong, hWnd, GWL_STYLE, eax ret pass_to_old: push lParam push wParam push uMsg push hWnd call OldWndProc ret这里面的OldWndProc是安装子类时SetWindowLongPtr返回的旧窗口过程指针。注意子类过程必须遵循标准窗口过程调用约定所有参数按hwnd/uMsg/wParam/lParam顺序传入返回值放到EAX中。往完整了说子类化方案比单纯摘样式更稳因为它不是“一次性偷看”而是把控件变成了一个“人形自走泄密器”任何一次WM_GETTEXT查询都会被记录下来。而这种方案本身也极易被目标程序检测属于典型的攻防对抗话题。4. 从“盗密码”到“防被盗”软件开发者该补的功课4.1 客户端密码框的局限明文总会出现在内存里折腾到这里结论已经非常清楚只要密码框在某个时刻收到过用户的明文密码且控件没有在第一时间把缓冲区清掉那么任何能在这台机器上以足够权限运行代码的进程都有机会通过各种手段看到内存里的明文。这不是某个控件或者某个 API 的疏漏而是所有客户端程序共有的结构性局限。程序要拿用户输入的密码去做登录、做密钥派生那就必须在某个环节把明文从控件里读出来。这个“读出来”的动作就已经让明文暴露在进程内存中了。哪怕你读取后立刻加密那一小段时间窗口也仍然存在。所以任何声称“客户端密码框绝对安全”的说法都是不成立的你能做的只是提高被读取的门槛降低风险窗口的长度。4.2 提高门槛的防御写法抛开复杂的反调试、反注入不谈先列出几条成本低、见效快的防御动作适合大多数普通 Win32 程序。第一个动作是密码框失去焦点或登录动作结束后立即把密码框文本清空。比如用户登录成功或失败后就对密码框执行WM_SETTEXT设置为空串。这样做至少让明文在控件缓冲区里停留的时间大幅缩短。更讲究一点程序自己持有的密码副本用完以后用SecureZeroMemory之类不可被编译器优化掉的清零函数擦除不要用普通循环避免优化器把“写但不再读”的内存清零当成无用代码干掉。第二个动作是警惕外部子类化。在你自己的关键窗口过程里可以定期检查窗口的GWLP_WNDPROC是否还是自己注册控件时返回的那一个。如果发现被换掉了说明有人往里插了东西轻则是调试辅助工具重则就是本文前面描述的攻击链路。发现异常后可以拒绝继续处理敏感消息或者直接弹出告警。第三个动作是尽量减少密码的中间副本。很多程序从控件里取出密码后为了拼接字符串方便先把密码拷进一个临时char[]再转成BSTR再转成哈希一来二去内存里同时存在三份明文。正确的做法是直接定义一个只用来做哈希的缓冲区一次拷贝后马上使用、马上清零。这类防御都不是绝对安全的但它们能把一次本可轻易得手的攻击变成一次需要专门适配的逆向工作在很多普通场景下已经足以劝退绝大多数脚本小子。4.3 合法使用场景与自动化测试的正路这套“拾取密码”的技术真正该出现在哪里呢我个人的实践是两类。一类是安全审计。你被要求评估某个内部系统或某个外采软件的密码框是否有明文残留于是用注入方案在自己有权限的测试环境里验证输出报告推动对方修复。这类工作正当、专业而且对细节要求非常高。另一类是自动化测试。产品里有个登录框你要写 UI 自动化用例需要输入密码并验证登录流程。最合理的做法是让测试版本支持从受保护的环境变量或测试配置文件读取密码直接通过消息注入到控件里而不是从界面上“捞回”用户密码。也就是说明文入口应该由测试框架主动提供而不是靠抓取真实用户的密码来跑测试。反过来如果哪个测试脚本要靠“拾取密码框文本”才能运转那大概率是产品设计出了问题说明你的自动化框架和产品之间的信任边界没有理清。在实现自己的安全工具时还有一个好习惯把代码中的窗口标题、控件 ID、目标进程名全部做成配置项不要写死。这样换一个测试程序只需要改配置不用改汇编代码。这一点也是我给所有想继续深挖逆向工程的朋友最实在的建议——工具是死的思路是活的真正值钱的是你判断“消息该往哪儿打、样式该在哪儿摘”的能力。汇编语言学到这一讲前面关于窗口句柄、消息循环、API 调用的知识基本都被串起来了。密码框本身并不神秘神秘的只是那层掩码样式把显示层和存储层分清楚后面无论是写安全工具还是给自己的软件做加固都会顺手很多。