☰
Windows密码框星号查看器:Python+ctypes读取明文原理与实战
2026/10/11 11:21:36 网站建设 项目流程

简介:星号密码查看器.zip 内含一款可运行在 Windows 环境下的密码显形工具,面向需要读取登录窗口中被星号遮挡的密码的开发人员、系统管理员及学习者。它通过识别窗口类名与句柄,对目标控件上的星号内容进行提取,便于密码找回、测试输入或代码调试。压缩包体积仅 384KB,共 4 个文件,主体为可直接运行的 exe 程序,另附 htm 格式的下载说明、txt 格式的使用说明以及一个 url 格式的互联网帮助快捷方式,能帮助用户快速上手并解决使用中遇到的问题。目前已有 392 人浏览学习,适合对 Windows 底层机制和系统编程感兴趣的入门者作为案例参考。从资料中可学习到 FindWindow、GetWindowText 等 WinAPI 的使用思路,理解窗口句柄、类名在 GUI 自动化中的意义;同时也能获得一个直接可用的密码查看器,方便日常维护和应急场景。需要注意,该工具仅应在获得授权的情况下使用,避免触碰隐私和法律红线。

1. 星号密码查看器:一个被误解的老工具,能解决什么问题

前阵子某同事找我,说某公司网盘客户端一直勾着“记住密码”,换了电脑要重新登录,密码死活想不起来。这种事几乎人人都遇到过:密码框里一排星号,你想看它后面到底是什么,系统却不给任何入口。星号密码查看器解决的就是这个问题——它不是黑客工具,不碰网络、不碰数据库,只向目标密码框所在的控件发一条WM_GETTEXT消息,请控件自己把明文交出来。显示层拦截了星号的绘制,但文本消息接口没有拦截,这是 Windows 兼容性的老设计。

这篇我按自己实际折腾过的路径来写:先讲清楚密码框的存储与显示为什么分离,再给一个十几行的 Python 最小脚本直接跑通,然后封装成带界面的小工具并打包,接着是读不到密码时的五个典型踩坑案例,最后把“读密码”接进自动化流水线。适合三类人:运维要做客户端自动化、测试要写 UI 脚本、普通用户要找回自己记住的密码。唯一的前提是只读自己设备或合法授权的目标,别拿它去做不该做的事。

2. 原理先行:密码框的星号只是一层“显示掩护”,不是加密

很多人第一次听说这个工具时,会下意识觉得它用了什么“内存扫描”“Hook 拦截”的黑科技。其实 Windows 的密码框远没有想象中神秘,它只是一个带了特殊样式的普通控件,星号只是绘制阶段的替换结果。

2.1 密码框的真实身份:一个开了 ES_PASSWORD 样式的 Edit 控件

在 Win32 体系里,你看到的登录框、密码输入框,绝大多数是标准 Edit 控件,类名就叫Edit。文本框和密码框是同一个控件类,唯一区别是创建时有没有带ES_PASSWORD样式(值为 0x0020)。带了这个样式,系统在重绘控件时就把真实字符用密码字符替代,默认是圆点,具体是什么可以通过EM_GETPASSWORDCHAR消息查出来。

关键点在于:明文一直存在控件内部的缓冲区里,也就是目标进程的堆内存中。星号处理发生在绘制阶段,不改动内部存储。这个设计意味着,只要你能拿到这个控件的句柄,就有机会把明文读出来。先给一段用来“验明正身”的代码,判断某个窗口是不是密码框,以及它用的是什么替代字符:

import ctypes from ctypes import wintypes user32 = ctypes.WinDLL("user32", use_last_error=True) GWL_STYLE = -16 ES_PASSWORD = 0x0020 WM_GETPASSWORDCHAR = 0x00D4 def is_password_edit(hwnd): style = user32.GetWindowLongPtrW(hwnd, GWL_STYLE) return bool(style & ES_PASSWORD) def password_char(hwnd): code = user32.SendMessageW(hwnd, WM_GETPASSWORDCHAR, 0, 0) return chr(code) if code else None

GetWindowLongPtrW取出的是控件样式的完整位标志,用ES_PASSWORD做与运算即可判断。WM_GETPASSWORDCHAR返回替代字符的 Unicode 码点,大多数情况下是 U+25CF(实心圆点)。这个判断在后面枚举子控件时会用到,能帮你从一堆文本框里精确筛出真正的密码框。注意GetWindowLongPtrW在 32 位 Python 里也能工作,但我在项目里统一用 64 位 Python,少踩一个数据宽度相关的坑。

2.2 消息循环里那条关键路径:WM_GETTEXT 为什么能拿到明文

Win32 窗口消息本质上是一次跨模块的函数调用。SendMessage是同步的:发送方阻塞等待控件处理完消息并返回结果。WM_GETTEXT的语义是“把控件中的文本复制到调用方缓冲区”,密码框控件处理这条消息时,并不区分自己有没有ES_PASSWORD样式,直接把内部缓冲区里的真实文本交出去了。

这个行为不是漏洞,而是延续了几十年的兼容性设计。辅助功能工具、自动化测试工具、屏幕阅读器都要靠这套消息接口干活,系统如果在这里拦截,大量老软件会直接失灵。所以从 API 层面看,读密码框明文就是一条标准路径,整个过程不需要注入、不需要 Hook:

WM_GETTEXTLENGTH = 0x000E WM_GETTEXT = 0x000D length = user32.SendMessageW(hwnd, WM_GETTEXTLENGTH, 0, 0) buf = ctypes.create_unicode_buffer(length + 1) got = user32.SendMessageW(hwnd, WM_GETTEXT, length + 1, buf) print(buf.value)

先用WM_GETTEXTLENGTH拿长度,再创建对应大小的缓冲区,length + 1多出来的一个位置放字符串终止符。create_unicode_buffer是 ctypes 里处理宽字符串的推荐方式,它会自动处理编码转换。这里有个细节:WM_GETTEXT的wParam传的是缓冲区能容纳的最大字符数,不是字节数;lParam传缓冲区指针,返回值是实际复制的字符数。EM_GETLINE之类的消息在密码框上是无效的,认准WM_GETTEXT就行。

2.3 选型:我为什么选 Python + ctypes 而不是 C#

做同一个工具,C# 的SendMessageP/Invoke 也很方便,甚至写出来更“正宗”。但我实际落地的选择是 Python + ctypes,原因很实际:整个逻辑只有几十行,Python 改起来快,不依赖 Visual Studio 工程,而且可以随时用print把句柄、长度、样式值打出来调试。C# 更适合做成长期分发给普通同事的正式小工具,打包体积小、双击即用。

方案上手速度运行时依赖打包体积适合场景
Python + ctypes快,十几行跑通Python 解释器,或 PyInstaller 打包较大,通常 20-60MB运维脚本、自动化测试、快速验证
C# WinForms中等,需 VS 工程.NET 运行时较小,几 MB分发给普通同事的固定工具

如果你只是自己用、自己调试,或者要把读密码这一步接进自动化流水线,Python 版本足够。如果你要做一个同事双击就能用的“傻瓜工具”,再考虑用 C# 重写一版也不迟,底层都是那一条消息,迁移成本很低。

3. 用 Python + ctypes 在本地跑通最小命令

原理说完就动手。整个工具拆成三步:找到目标窗口、在窗口里找到密码框控件、把密码文本读出来。下面每一步都给完整代码和参数说明,照着敲就能跑。

3.1 按窗口标题定位:FindWindowW 与遍历兜底

最直接的定位方式是按窗口标题找顶层窗口。FindWindowW接受两个参数,第一个是类名,传None表示不限制类名;第二个是窗口标题。注意必须用带W后缀的宽字符版本,否则中文标题会乱码:

import ctypes from ctypes import wintypes user32 = ctypes.WinDLL("user32", use_last_error=True) def find_window_by_title(title): hwnd = user32.FindWindowW(None, title) return hwnd

实际使用时,FindWindowW经常因为标题里含隐藏字符、标题栏动态变化而返回 0。我一般会再写一个遍历兜底:用EnumWindows枚举所有顶层窗口,逐个读标题做包含匹配。这样哪怕目标软件在标题里加了版本号、用户名,也能靠关键词捞到:

EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p) def find_window_by_keyword(keyword): result = [] def enum_callback(hwnd, _): length = user32.GetWindowTextLengthW(hwnd) buf = ctypes.create_unicode_buffer(length + 1) user32.GetWindowTextW(hwnd, buf, length + 1) if keyword in buf.value: result.append(hwnd) return True user32.EnumWindows(EnumWindowsProc(enum_callback), None) return result

EnumWindows的回调返回True继续枚举,返回False停止。这里用GetWindowTextLengthW先取标题长度,避免固定缓冲区截断长标题。这个版本比FindWindowW更可靠,代价是慢一点点,但几毫秒的差距可以忽略。

3.2 枚举子控件,锁定真正的密码框

找到顶层窗口后,密码框通常是它的子控件。用EnumChildWindows遍历所有后代窗口,先过滤类名必须是Edit,再用上一章的is_password_edit确认带不带ES_PASSWORD样式。双重过滤能有效避开那些普通文本框:

def find_edit_controls(parent_hwnd): edits = [] def child_callback(hwnd, _): class_buf = ctypes.create_unicode_buffer(256) user32.GetClassNameW(hwnd, class_buf, 256) if class_buf.value == "Edit" and is_password_edit(hwnd): edits.append(hwnd) return True user32.EnumChildWindows( parent_hwnd, EnumWindowsProc(child_callback), None ) return edits

GetClassNameW取控件类名,256 字符的缓冲区足够用了,标准控件类名长度不会超过这个数。有些自绘界面类名可能是Edit的变体,比如加了边框样式的Edit还是同一个类,这步过滤基本够用。如果目标窗口里有多个密码框(比如“旧密码”“新密码”“确认密码”),这个函数会全部列出来,后续可以按控件坐标进一步过滤,或者直接全部打印出来人工判断。

3.3 发送 WM_GETTEXT 把明文取回来

现在把读文本封装成一个函数,然后把前面的步骤串起来。注意WM_GETTEXT对缓冲区长度为零的控件会直接返回 0,所以先查长度能省掉无谓的分配:

def get_edit_text(hwnd): length = user32.SendMessageW(hwnd, WM_GETTEXTLENGTH, 0, 0) if length <= 0: return "" buf = ctypes.create_unicode_buffer(length + 1) got = user32.SendMessageW(hwnd, WM_GETTEXT, length + 1, buf) return buf.value def get_password_from_window_title(title): hwnd = find_window_by_title(title) if not hwnd: print("找不到窗口:", title) return None for edit_hwnd in find_edit_controls(hwnd): pw = get_edit_text(edit_hwnd) if pw: print("密码:", pw)

create_unicode_buffer(length + 1)中的+1是给宽字符串终止符留的位置,少了这一个字符,SendMessageW写入时可能越界。实际运行中,如果目标窗口是当前登录窗口且密码框里有内容,这段代码会直接打印出明文。多个密码框都非空的话,会依次打印,顺序一般和控件创建顺序一致。

3.4 一行命令跑起来:参数化入口

手动测试时每次改代码里的标题很烦,我把入口改成命令行参数,这样对自动化脚本也友好:

import sys if __name__ == "__main__": if len(sys.argv) > 1: get_password_from_window_title(sys.argv[1]) else: print("用法: python pwview.py \"窗口标题\"")
python pwview.py "某客户端登录窗口"

这个命令行的好处是能被其他脚本直接调用,也可以接到自动化流程里。如果你只是临时看一眼,也可以把sys.argv改成input(),运行时粘贴标题,效果一样。

4. 把脚本封装成可用的图形小工具:从命令行到双击即用

脚本能在命令行里跑通之后,下一步是给它一个界面。我自己用命令行就够了,但如果你要把它交给不碰终端的同事,或者自己也想省点事,用 PySide6 套一层壳是最快的路径。

4.1 界面骨架:输入标题、读密码、显示结果

先安装依赖,然后写一个最简单的窗口:一个输入框填窗口标题,一个按钮触发读取,一个标签显示结果:

pip install PySide6
import sys from PySide6.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QLineEdit, QPushButton, QLabel ) class PasswordViewer(QWidget): def __init__(self): super().__init__() self.setWindowTitle("星号密码查看器") layout = QVBoxLayout(self) self.title_input = QLineEdit() self.title_input.setPlaceholderText("输入或悬停获取窗口标题") layout.addWidget(self.title_input) self.btn = QPushButton("读取密码") self.btn.clicked.connect(self.read_password) layout.addWidget(self.btn) self.result_label = QLabel("结果会显示在这里") self.result_label.setTextInteractionFlags( self.result_label.textInteractionFlags() | 1 ) layout.addWidget(self.result_label) def read_password(self): title = self.title_input.text().strip() passwords = get_password_from_window_title(title) if passwords: self.result_label.setText("\n".join(passwords)) else: self.result_label.setText("未读到位,权限或控件类型问题") if __name__ == "__main__": app = QApplication(sys.argv) win = PasswordViewer() win.show() sys.exit(app.exec())

界面本身不包含任何取密码逻辑,只是把第三章的get_password_from_window_title包装进了按钮回调。setTextInteractionFlags让结果可以被选中复制,这是工具类界面一个容易被忽略的细节——不能复制的结果等于没结果。

4.2 鼠标悬停自动抓窗口标题

让用户手敲标题还是不够方便,尤其目标窗口标题很长或带空格时。我在界面里加了一个悬停抓取:鼠标移到目标窗口的标题栏上,界面自动把标题填进输入框,省掉手动拷贝。实现上用QTimer每 200ms 轮询一次鼠标位置,再调用WindowFromPoint拿句柄、读标题:

from PySide6.QtCore import QTimer import ctypes def grab_title_under_mouse(): pt = wintypes.POINT() user32.GetCursorPos(ctypes.byref(pt)) hwnd = user32.WindowFromPoint(pt) if not hwnd: return length = user32.GetWindowTextLengthW(hwnd) if length <= 0: return buf = ctypes.create_unicode_buffer(length + 1) user32.GetWindowTextW(hwnd, buf, length + 1) title = buf.value if title: win.title_input.setText(title) timer = QTimer(win) timer.timeout.connect(grab_title_under_mouse) timer.start(200)

为什么用轮询而不是鼠标事件?Windows 没有暴露全局的“鼠标悬停到某窗口”事件,最通用、依赖最少的方式就是定时采样。200ms 的间隔既不卡界面,也不会漏掉短暂停留。注意WindowFromPoint返回的是屏幕上该坐标处最顶层的窗口,如果鼠标悬在密码框上,拿到的可能是子控件,此时标题往往为空,直接跳过即可。

4.3 打包成单文件 exe:参数与杀毒误报

打包用 PyInstaller,命令不长但有几个参数值得说清楚:

pip install pyinstaller pyinstaller -F -w --uac-admin --clean --name PasswordViewer pwview_gui.py

-F是生成单文件 exe,-w是去掉控制台窗口(GUI 程序不需要黑框),--uac-admin是让生成的 exe 默认请求管理员权限,--clean清理上次编译缓存。打包前需要确认你用的是 64 位 Python,这直接影响后续能不能读到 64 位目标进程的窗口,具体原因在下一章讲。

注意:--uac-admin会让每次启动都弹 UAC 确认框,而且杀毒软件对带提权 manifest 的小工具误报率会更高。我自己的习惯是:先不打这个参数,遇到权限读不到的窗口再重新打包一个提权版本。

杀毒误报是这个工具绕不开的痛。ctypes直接调SendMessage跨进程读窗口内容,在静态扫描的特征库里非常像敏感行为,加上 PyInstaller 打包壳,被隔离是常有的事。我一般会在-w之外尽量少加其他混淆参数,减小壳的体积和异常特征。给自己的机器用就源码运行,给同事分发前先在 VirusTotal 上扫一眼,心里有数。

5. 避坑与排查:读不到密码的5个真实案例

读不到密码时,大部分问题都集中在进程位数、权限、控件类型这三件事上。下面每条都是我至少遇到过一次的真实场景,按现象、原因、解决的顺序写,遇到类似问题可以直接对照。

5.1 工具是 32 位,目标窗口是 64 位:SendMessage 直接静默失败

现象:窗口句柄找到了,甚至能枚举出Edit控件,但WM_GETTEXTLENGTH返回 0,WM_GETTEXT拿到的也是空字符串。

原因:在 64 位 Windows 上,32 位进程向 64 位进程发送窗口消息本就受到架构隔离限制,再加上完整性级别隔离(UIPI),跨位数发消息经常被静默丢弃。工具自己很可能是用 32 位 Python 写的,或者打包时用了 32 位解释器。

解决:用 64 位 Python 重新跑脚本、重新打包。判断方法很简单:在 Python 里打印ctypes.sizeof(ctypes.c_void_p),结果是 8 就是 64 位,是 4 就得换解释器。发布 exe 给同事时,优先构建 64 位版本,能少掉一半兼容性问题。

提示:发布工具时优先做 64 位版本,兼容性和稳定性都更好。

5.2 目标窗口以管理员运行,工具没提权:句柄拿得到但消息被系统丢弃

现象:窗口标题匹配正常,控件也能枚举出来,但读取结果始终为空。换了一个普通权限的测试窗口,立刻就能读到。

原因:Windows 的完整性级别隔离(UIPI)禁止低完整性进程向高完整性进程发送消息。目标软件以管理员权限运行,完整性级别高,你的普通权限工具发的WM_GETTEXT会被系统直接丢弃,SendMessage 不报错、只是不生效。

解决:让工具也以管理员权限运行。一种是右键 exe“以管理员身份运行”,一种是用 PyInstaller 的--uac-admin参数打包出提权版本。注意 UAC 提权后整个工具都运行在高完整性级别,读取逻辑不用改,权限够了消息就能送达。

5.3 目标不是标准 Edit 控件:枚举不到密码框,读了个寂寞

现象:枚举子控件时,类名列表里根本没有Edit,或者类名是Edit但ES_PASSWORD样式压根没设置。还有更极端的:连子控件都枚举不到。

原因:新版客户端大量使用自绘 UI,比如某个 Electron 套壳、某个 Tauri 程序、Java Swing 界面。这些框架的密码输入框不是原生 Win32 控件,星号是框架自己画的,明文要么存在框架自己的私有内存里,要么就是绘制结果,标准消息机制根本够不着。

解决:先花半分钟确认控件类型,别在自绘 UI 上浪费时间。用EnumChildWindows打印出所有子控件的类名,亲眼看到没有Edit类名,就可以判断这条路走不通。这类程序要读密码得走 UI Automation 的ValuePattern,或者直接查它自己的配置存储。血泪经验:确认控件类型后再决定方案,能省下一整个下午。

5.4 中文标题匹配不上:A/W API 混用与不可见字符

现象:窗口标题明明是中文,FindWindowW却返回 0。有的脚本里用了FindWindowA,而 ANSI 版本在中文标题上会直接乱码,匹配永远不会成功。

原因:两个层面。一是 API 版本,A 后缀是 ANSI 版本,在处理非 ASCII 字符时按系统默认代码页转换,中文环境可能没问题,但跨区域部署就出错;二是标题字符串可能包含全角空格、零宽空格等不可见字符,你看到的标题和系统里存的标题不完全一样。

解决:统一用W后缀的宽字符 API,Python 的str天生是 Unicode,传给FindWindowW不需要额外编码处理。排查不可见字符时打印repr(title),一眼就能看出有没有特殊符号。再不行就用 3.1 节的EnumWindows包含匹配,别死磕精确标题。

5.5 杀毒与 EDR 实时拦截:exe 刚生成就被隔离

现象:PyInstaller 打包完,exe 目录里还热乎着,杀毒软件直接隔离;或者运行时弹风险提示,点允许之后读取结果也是空的。

原因:跨进程读窗口内容在安全产品眼里是敏感行为,静态扫描命中“读取其他进程数据”的特征码,加上提权 manifest,触发概率更高。这不是误报那么简单,而是行为本身就符合监控规则。

解决:分场景处理。自己用就源码运行,别打包;要分发,先把 exe 加入杀毒软件白名单,再发给同事时明确说明用途和原理。这个工具的合规边界很清晰:只用于自己设备、自己账号、被授权的系统。任何绕开他人授权读取密码的做法都不该做也不用做,技术上也没有必要。

6. 进阶技巧:把“读密码”变成自动化流水线的一环

工具做到这里,功能其实完整了。但真正让它发挥价值的地方,是嵌进自动化流程里当“取密码”的管道。

6.1 最小自测窗口:五分钟验证工具本身

调试这个工具时,我不建议拿真实业务软件反复试,容易碰到各种权限和控件问题。更快的办法是自己先建一个目标窗口,用标准的密码框验证工具链是否正常:

import tkinter as tk root = tk.Tk() root.title("演示登录窗口") entry = tk.Entry(root, show="*") entry.pack(padx=20, pady=10) entry.insert(0, "test_pass_123") root.mainloop()
python pwview.py "演示登录窗口"

如果这个窗口能读出test_pass_123,说明你本机的 Windows 消息链路没问题,后面排查真实目标时就排除了环境因素。这个小窗口也是验证打包后的 exe 是否正常、杀毒是否拦截的最好道具。

6.2 读密码 + GUI 自动登录的组合姿势

有次帮同事处理某内部系统的自动化登录,密码被客户端存成了加密配置,数据库里也是哈希,绕了大半天,最后就是从旧版客户端的密码框里把明文捞回来,流程才跑通。那次之后我常把这个工具和 GUI 自动化工具拼在一起用:

import pyautogui import time title = "某内部系统登录窗口" pw = get_password_from_window_title(title) pyautogui.click(200, 400) # 用户名框,坐标按实际界面调整 time.sleep(0.3) pyautogui.write("某用户名") pyautogui.press("tab") time.sleep(0.3) pyautogui.write(pw) pyautogui.press("enter")

组合的时候注意两件事:一是读密码要在窗口已经加载完成后进行,否则密码框还没创建好;二是pyautogui的坐标依赖界面布局,窗口位置变了就翻车,我一般会先用pyautogui.locateOnScreen找控件位置再点击,而不是写死坐标。这套组合最典型的应用场景是:软件实现了“记住密码”,但用户要的是明文,或者自动化脚本需要在无人值守时完成登录。读出来的密码拿到手后,再按需写进配置、填入表单或做交接记录。

这个工具我放在常用目录里,平时几乎用不上,但真到用的时候能省下小半天。它的核心逻辑就那几十行,难的不是读,而是知道什么时候该用、什么时候不该用。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询