写这一讲之前我犹豫了一会儿,因为“拾取密码框中的密码”这八个字,听起来确实有点像要教人干坏事。但拆开看,它其实是一个非常纯粹的Windows编程问题:一个带ES_PASSWORD样式的Edit控件,把用户敲进去的字符用星号或圆点显示出来,那底层明文到底藏在哪里?在什么条件下能被同一个桌面上的其他进程合法取回?这背后涉及的窗口消息、控件的窗口过程、样式的运行时切换、DLL注入,全是汇编学习者绕不开的知识点。
这篇文章的代码本质上就是一个“密码查看器”,我先把适用范围说清楚:最正当的用法,是给自己写的登录对话框做UI自动化测试、排查输入框的内存清理问题,或者做安全审计时评估第三方程序的内存暴露面。未经授权去读取别人程序的密码框内容,那超出了技术讨论的合法边界,这点我默认大家都懂。下面我们把原理拆到底,再一步步写出可运行的汇编程序。
1. 先从一句大实话开始:星号背后从来不是加密
1.1 ES_PASSWORD样式到底改了些什么
Windows的密码框不是什么特殊的“密码控件”,它就是一个普普通通的Edit控件,只不过创建时多了一个窗口样式:ES_PASSWORD。这个样式的作用范围非常窄,基本只影响两件事:绘图和剪贴板相关操作。
当Edit控件带着ES_PASSWORD样式重绘时,窗口过程不会把缓冲区里的真实字符画出来,而是对每个字符都用同一个密码字符替代,默认是星号或者圆点。你还可以用EM_SETPASSWORDCHAR消息把替代字符改成任意你想用的字符。也就是说,用户输入P@ssw0rd,屏幕上出现的是********,但控件内部保存的字符串仍然是完整的P@ssw0rd。
打个比方:这就好比你在纸上写了身份证号,然后拿一张便签纸盖住,便签纸上只画了几个黑点。黑点不是把身份证号加密了,它只是挡住了视线。你掀开便签纸,数字还在同一个位置。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 那种完全走自己的渲染管线的控件,那么类名根本不是标准Edit,SendMessage(WM_GETTEXT)也未必有对应实现。判断控件类型,永远比写代码更优先。
还有一个隐藏坑:缓冲区太小会导致取回的文本被截断;WM_GETTEXT不会告诉你字符串实际有多长,它只按你给的wParam尽量拷贝。稳妥做法是先发WM_GETTEXTLENGTH拿到长度,再分配足够大的缓冲区。我在这段示例里直接用了 256 字节,演示够用,真实工具别偷懒。
3. 现代系统上的组合拳:DLL注入加临时摘掉ES_PASSWORD
3.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 位系统上通常直接用SetWindowLong,64 位上换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 调用的知识基本都被串起来了。密码框本身并不神秘,神秘的只是那层掩码样式;把显示层和存储层分清楚,后面无论是写安全工具还是给自己的软件做加固,都会顺手很多。