ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

纯Go实现Windows星号密码查看器:Shellcode注入与Win32 Syscall实战

纯Go实现Windows星号密码查看器:Shellcode注入与Win32 Syscall实战 不用绕弯子直接说结论Windows 星号密码查看器这类工具本质上是和 Win32 编辑框控件斗智斗勇。密码框在界面上显示成圆点或星号只是系统在绘制阶段做了“脱敏”文本内容其实原封不动地躺在控件内部缓冲区里。这篇文章会把一个纯 Go 实现的方案完整拆开从远程线程 Shellcode 注入、Win32 Syscall 调用、窗口句柄枚举到最后把隐藏的明文安全取出来。适合正在折腾 Windows 底层编程、做 UI 自动化、或者纯粹想搞明白“密码框那层窗户纸”的开发者。这个标题里三个关键词值得先展开说。纯 Go意味着不用 CGO不依赖 mingw 编译环境一个 go build 就能出 exeWin32 Syscall 指直接通过 golang.org/x/sys/windows 调用系统 API绕开 C 运行时Shellcode 注入则是整个工具的灵魂——它解决一个核心矛盾你从进程外部用 SendMessage(WM_GETTEXT) 读密码系统会返回一串星号但在目标进程内部调用 GetWindowTextW拿到的却是明文。所以方案变成了“把一小段代码送进目标进程让它在里面替你把文本读出来”。1. 先把这个工具彻底想明白1.1 密码框“星号”到底挡在哪一层很多人第一次写星号密码查看器时第一反应是往密码框发 WM_GETTEXT 消息。我也这么干过实验结果很讽刺拿到的是一串和显示区域等长的星号。原因是 Win32 编辑框控件在创建时如果带上 ES_PASSWORD 样式内部会置一个“密码模式”标志位。当外部通过 WM_GETTEXT、GetWindowText 这类跨进程消息获取文本时控件会把真实字符串替换成掩码字符返回。这个“挡”的动作发生在控件内部的消息处理逻辑里而不是发生在内存里。你可以在 Spy 里挂上消息钩子对同一个句柄连续发 WM_GETTEXT无论发多少次都是掩码。哪怕调用 GetWindowTextW 也一样。控件知道当前是否处于密码模式也知道调用者是谁。想要跨过这层常规思路有几个一是向控件发送 EM_SETPASSWORDCHAR 消息把掩码字符设成一个能显示的空字符再截屏 OCR但这一招对已经绘制出来的界面无效密码被吃掉的字符也已经涂抹掉了而且很多现代界面库不吃这套。二是直接读进程内存Windows 的 Edit 控件内部结构EditBox 私有结构里存着文本缓冲区和长度但版本差异很大Win10、Win11、不同语言框架原生 Win32、MFC、WPF、DirectUI内部偏移全不一样维护成本极高。三是“把代码送进去”也就是注入 Shellcode在目标进程的地址空间里调用系统 API让 API 自己去返回明文——这是最优雅、也最稳定的路线。1.2 为什么选纯 Go Syscall Shellcode 注入先说我走过的弯路。第一版我用 C 写了个 DLL通过 CreateRemoteThread 加载到目标进程里让 DllMain 里跑读取逻辑。这套可行但坑很多目标进程如果已经加载了同版本 DLL 会报错卸载时容易把进程搞崩还必须为 32 位和 64 位目标分别编译两个 DLL工具自己还得带个释放器。后来我看有人用纯汇编写 shellcode发现真正被加载的其实只是一百多个字节的机器码不涉及 DLL 加载器、PE 结构、重定位这些复杂逻辑反而干净。选 Go 的原因更简单跨平台开发和交叉编译体验好而且 golang.org/x/sys/windows 这个包把 CreateRemoteThread、VirtualAllocEx、WriteProcessMemory 这些关键 API 的封装都写好了。当年在 Go 里调 Win32 API 还要自己手写 syscall.Syscall 的胶水代码现在直接用 windows.OpenProcess、windows.VirtualAllocEx 就行参数类型、错误处理全是强类型比 C 里一堆手写宏舒服太多。Shellcode 注入的方案可以做到“不落地文件”。DLL 注入不管怎么做总要有一个 DLL 文件在磁盘上存在而 Shellcode 是一段纯字节串通过 WriteProcessMemory 写进目标进程的内存然后 CreateRemoteThread 拉起一个线程执行。对安全软件来说这种操作更隐蔽当然也更容易被盯上后面专门讲对我们开发者来说则是不用管临时文件清理。1.3 工具最终形态与适用场景我最后交付的形态是一个单文件命令行工具不带 GUI。交互方式先列出当前 Session 下所有可见窗口用户指定进程名或窗口标题工具自动枚举该窗口下的所有子窗口筛选出带 ES_PASSWORD 样式或属于密码输入控件比如 Windows 的 Credential Manager 对话框、自家软件里的密码修改框的句柄然后注入 shellcode 把明文读出来打印到控制台。输出到控制台这个设计一开始被同事吐槽过“密码你打印在终端里日志一留不是更不安全吗”所以后来加了一个-silent模式把结果写到指定的导出文件或者通过剪贴板直接发送。做成命令行也有额外好处可以写进自动化脚本里比如 UI 自动化测试跑完后自动提取测试账号的密码框输入内容做断言。适用场景我列一下这也是我在 README 里明确写的边界找回自己开发的软件里遗忘的本地密码配置项密码框不回显。UI 自动化测试中核对密码输入框的实际内容。在你有权管理的机器上排查“某个程序记住了密码但我忘了是什么”。安全研究、CTF、逆向调试中分析目标程序的内存状态。反过来说如果你准备拿它去读别人机器上、别人程序里的密码我劝你趁早打消这个念头。本文后面的代码思路只对你拥有完整操作权限、或者你自己开发调试的程序有意义。2. 关键知识点Win32 句柄、消息与密码框机制2.1 ES_PASSWORD 与 WM_GETTEXT 的拦截逻辑Win32 编辑框是标准的 User32 控件密码模式的源头是 CreateWindowEx 创建时的样式位 ES_PASSWORD0x0020。CreateWindowEx 之后可以随时通过 SetWindowLongPtr 修改这个样式位来开启或关闭密码模式。系统内部维护了一个标志位 pPassword它与样式位同步但只影响两个行为一是绘制时用掩码字符替代文本二是对外发送的文本查询消息时对内容做脱敏。注意一个细节WM_GETTEXT 的脱敏是“可见性”层面的而不是把内存里的字符串替换掉。控件内部保存明文的缓冲区没有被改动系统只是在外发消息时构造了一个临时字符串返回。所以从底层来看明文一直就在那里问题只是怎么拿到它。用 RtlSecureZeroMemory 这类 API 清理密码缓冲区以及用 CREDENTIAL_MANAGER 来做密码存储都属于程序自身的安全设计。但一个残酷的现实是只要程序运行起来了明文最终必须被渲染到控件里你就总有机会在它渲染那一下做文章。这也是为什么很多安全工具会直接钩子 GetWindowTextW、甚至 DirectComposition 层抓帧来做“密码嗅探”。我们这里不搞那么复杂只利用一个原则系统 API 在进程内部读取时不会对自己人设防。2.2 枚举窗口的三件套实战要点要拿到目标密码框的句柄通常三步走FindWindowW 按标题找顶层窗口或者 EnumWindows 遍历所有顶层窗口找到目标顶层窗口后EnumChildWindows 遍历它的所有子控件对每个子控件检查样式位、类名和文本特征。代码里一个关键点是 EnumChildWindows 需要回调函数。Go 的 syscall.NewCallback 可以把一个 Go 函数转成系统能调用的函数指针但这个回调里不能做复杂操作只能把句柄收集到切片里枚举结束后再统一分析。实测中的教训是在回调里调用 windows.GetWindowTextW 没问题但千万别在回调里分配大切片或做日志写入回调在系统上下文里执行栈空间有限稍不留神就会崩。var hwnds []windows.HWND syscall.NewCallback(func(hwnd windows.HWND, lParam uintptr) uintptr { hwnds append(hwnds, hwnd) return 1 // 继续枚举 })拿到句柄后判断一个 Edit 控件是否是密码框style, err : windows.GetWindowLongPtr(hwnd, windows.GWL_STYLE) if err ! nil { return false } isPassword : stylewindows.ES_PASSWORD ! 0这个方法对原生 Win32 和 MFC 程序有效因为它们的密码框确实是标准 Edit 控件。但微软自家的很多新界面比如设置中心的某些输入框用的是 DirectUI 的 XAML 框架密码框可能是一个自绘控件、内部套着一层 Edit 控件光是字面检查样式是查不到的。对这种情况我加了另一个启发式判断文本框的类名是 Edit但又设置了 WantReturn、ES_UPPERCASE 等特征再尝试读文本如果返回的字符串全部是星号或圆点但文本长度不为零就当成候选密码框处理。2.3 从窗口句柄定位目标进程与权限协商有了句柄下一步是拿到进程 ID 和线程 ID并打开进程句柄以便注入。关键调用是 GetWindowThreadProcessId。注意这个函数返回的是线程 ID 不是进程 ID用第二个参数拿进程 ID。打开进程的时候权限参数不要一上来就 PROCESS_ALL_ACCESS。一来权限要求太高容易被系统安全策略拦二来没必要。最小权限组合是PROCESS_CREATE_THREAD允许远程创建线程。PROCESS_VM_OPERATION允许 VirtualAllocEx / VirtualFreeEx。PROCESS_VM_WRITE允许 WriteProcessMemory。PROCESS_VM_READ允许 ReadProcessMemory。PROCESS_QUERY_INFORMATION查询进程信息不是必须但便于做位数检查。如果目标进程以管理员权限运行而我们的工具没有提权OpenProcess 会返回 ERROR_ACCESS_DENIED5。这个坑几乎必踩。解决方式有两个要么把工具也提权到管理员运行manifest 加 requireAdministrator要么启用 SeDebugPrivilege 再去 OpenProcess。后面第 5 节我会专门展开。还有一点如果目标是系统级进程比如 lsass.exe以 SYSTEM 运行普通管理员权限依然打不开必须 SYSTEM 权限。但正常密码查看场景很少碰系统进程这里不展开。3. Shellcode 注入远程进程里的“影子线程”3.1 Shellcode 方案为什么能绕开 WM_GETTEXT 的屏蔽我们前面说的跨进程 WM_GETTEXT 被脱敏本质是 User32 模块在“跨进程消息”路径上做了特别处理。但如果你在目标进程内部直接调用 GetWindowTextW 这个函数传入参数是同一个句柄、同一块缓冲区系统在判断调用方时发现和控件处在同一线程上下文就直接返回内部文本了。这是一个非常微妙的区别。跨进程消息路径会经过 User32 内部的消息封送marshaling它会先检查控件状态再决定返回什么而进程内直接调用走的是快速路径函数直接去控件内部数据结构取文本。Shellcode 的作用就是把这句“进程内调用 GetWindowTextW”变成现实。为什么不直接远程调用因为 CreateRemoteThread 只接受一个参数而且你没法从外部把 user32.dll 里函数的地址直接当作线程入口传进去——你需要先知道目标进程里 GetWindowTextW 的确切地址然后把参数打包成一个结构体再让注入的代码自己找到这个地址并调用。这整个逻辑只能靠一段位置无关的机器码完成。3.2 手写 Shellcode 的上下文与布局设计我用的 64 位 Shellcode 是在 NASM 里写汇编、编译后导出.bin文件再转换成 Go 字节数组嵌入。写入的代码要做的几件事从传入的参数结构体地址里取出目标窗口句柄8 字节。调用 GetWindowTextW函数地址也放在参数结构体里由主程序解析后填入。把返回的字符串长度和内容写入一块共享缓冲区中。回到线程入口处加载一个特殊值到 RAX然后执行ret退出线程。这里有个非常重要的点Shellcode 里不能直接引用任何绝对地址因为这段代码被加载到哪个地址是不确定的。所有需要的外部地址user32.dll 的 GetWindowTextW和窗口数据都必须作为一个结构体放在内存里Shellcode 开头的第一条指令就是从这个结构体基地址开始偏移解析。结构体布局我设计成这个样子偏移大小内容0x008目标窗口句柄 HWND0x088GetWindowTextW 函数指针0x108输出缓冲区指针0x184输出缓冲区长度字符数0x1C4填充对齐Shellcode 本身只做两件事解析结构体、调用一个函数、把结果写回。Windows 线程从 CreateRemoteThread 创建后不会自己清理栈所以 Shellcode 里用到的栈空间必须自己平衡好在 GetWindowTextW 的调用约定Windows x64 的 Microsoft x64 calling convention要求在调用前让 RSP 按 16 字节对齐我在汇编里手动处理了这个对齐不然一调用就容易崩。整整写下来这段 Shellcode 大概不到 200 字节。你可以直接用 NASM 编译出.bin也可以用一个 Shellcode 生成器在线转。Go 端把它嵌进二进制var shellcode []byte{ 0x48, 0x8B, 0x01, // mov rax, [rcx] 0x48, 0x8B, 0x51, 0x08, // mov rdx, [rcx8] // ... 后续指令 }实际操作中我不想手抄机器码所以在构建流程里加了一步先让 NASM 编译汇编源文件为.bin再用go-bindata或简单的脚本把它转成一个.go源文件。这样做的好处是汇编源码可维护、可读机器码不用每次手算偏移。3.3 用 Go 调用 Win32 API 完成注入闭环注入的对外 API 用golang.org/x/sys/windows里的函数封装完整流程是主程序拿到目标窗口句柄通过GetWindowThreadProcessId获取 PID。用windows.OpenProcess打开目标进程。windows.VirtualAllocEx在远程进程里分配一块PAGE_EXECUTE_READWRITE内存。第一次WriteProcessMemory写入参数结构体第二次写入 Shellcode 字节。windows.CreateRemoteThread创建远程线程入口指向 Shellcode 基址。windows.WaitForSingleObject等待线程退出。windows.ReadProcessMemory读取输出缓冲区里的明文。windows.VirtualFreeEx释放远程内存windows.CloseHandle关闭句柄。这段流程我在 Go 里的实现大概长这样procHandle, err : windows.OpenProcess( windows.PROCESS_CREATE_THREAD|windows.PROCESS_VM_OPERATION| windows.PROCESS_VM_WRITE|windows.PROCESS_VM_READ| windows.PROCESS_QUERY_INFORMATION, false, pid) if err ! nil { log.Fatalf(OpenProcess failed: %v, err) } defer windows.CloseHandle(procHandle) remoteBuf, err : windows.VirtualAllocEx(procHandle, nil, uintptr(len(shellcode)unsafe.Sizeof(paramStruct)), windows.MEM_COMMIT|windows.MEM_RESERVE, windows.PAGE_EXECUTE_READWRITE) if err ! nil { log.Fatalf(VirtualAllocEx failed: %v, err) }CallCreateRemoteThread 的时候有个细节线程函数入口参数就用 remoteBuf也就是把整个结构体和 Shellcode 的起始地址传进去。但注意Shellcode 的第一条指令要能从 RCXWindows 线程函数的第一个参数读到结构体地址。如果我在汇编里第一条指令是mov rax,[rcx]那 RCX 指向的就是结构体起始地址。这套约定要预先定好代码逻辑和注入逻辑必须一致。等线程跑完后用 ReadProcessMemory 读回输出缓冲区。注意 GetWindowTextW 写入的是 UTF-16LE 编码Go 里要用windows.UTF16ToString转成字符串再打印。4. 完整实操流程与代码骨架4.1 环境准备与编译要点工具我用 Go 1.21 开发依赖只有golang.org/x/sys/windows。你要复现先把 Go 装上然后go get golang.org/x/sys/windows go build -ldflags-H windowsgui -o password_viewer.exe main.go-H windowsgui是为了隐藏那个碍眼的黑色控制台窗口因为工具有时候会作为常驻进程跑不弹黑框用户体验好很多。但注意如果隐藏了控制台窗口你又没做 GUI那工具就变成“无头”模式了。我实际做成了双模式默认带控制台打印结果-silent时隐藏控制台并写文件。编译目标如果是 64 位系统set GOARCHamd64如果你要查看老 32 位程序需要交叉编译 386 版本set GOOSwindows set GOARCH386 go build -o pwd_viewer_386.exe .千万记住Shellcode 也必须对应位数。64 位工具配 64 位 Shellcode32 位工具配 32 位 Shellcode混着来必崩。4.2 完整注入读取密码的五步实操整个工具跑起来分几个阶段。第一步是窗口筛选。这一步允许用户直接输入窗口标题关键词工具内部用 FindWindowW 或者 EnumWindows 遍历比对标题。我加了一个-list参数先列出当前可见窗口供用户挑选[0] 4005 - 计算器 [1] 6670 - 登录窗口 - Internet Explorer [2] 12890 - 我的测试程序 - Form1第二步是枚举子窗口。对选定窗口调用 EnumChildWindows把句柄收集齐。第三步筛选密码控件。按类名“Edit”、样式含 ES_PASSWORD、或文本是纯星号来过滤。第四步是执行注入读取。这一步走第 3 节讲的完整注入流程。第五步是输出明文默认直接 stdout 打印带-silent时写入指定文件。为了能处理“顶层窗口是登录框但密码框在子窗口深处”的情况我在枚举子窗口时加了深度限制参数最多递归三层避免递归过深卡在某个复杂 UI 树里。4.3 关键代码骨架枚举、注入、读取下面这段是从真实项目里精简出来的核心代码骨架重点看注入流程和结果读取。package main import ( fmt log unsafe golang.org/x/sys/windows ) // 目标进程必须已经有窗口存在 func readPasswordFromHwnd(hwnd windows.HWND) (string, error) { // 1. 取 PID var pid uint32 windows.GetWindowThreadProcessId(hwnd, pid) if pid 0 { return , fmt.Errorf(invalid pid for hwnd %v, hwnd) } // 2. 检查位数核心坑见第 5 节 if err : checkArch(pid); err ! nil { return , err } // 3. 解析 GetWindowTextW 地址 user32 : windows.NewLazySystemDLL(user32.dll) getWindowTextW : user32.NewProc(GetWindowTextW) fnAddr : getWindowTextW.Addr() // 4. 分配远程内存写入参数结构体和 shellcode procHandle, err : windows.OpenProcess( windows.PROCESS_CREATE_THREAD|windows.PROCESS_VM_OPERATION| windows.PROCESS_VM_WRITE|windows.PROCESS_VM_READ| windows.PROCESS_QUERY_INFORMATION, false, pid) if err ! nil { return , err } defer windows.CloseHandle(procHandle) type paramBlock struct { Hwnd uintptr FnPointer uintptr OutBuf uintptr OutLen uint32 _ uint32 } outBuf : make([]uint16, 4096) remoteOut, _ : windows.VirtualAllocEx(procHandle, nil, unsafe.Sizeof(paramBlock{}), windows.MEM_COMMIT|windows.MEM_RESERVE, windows.PAGE_READWRITE) p : paramBlock{ Hwnd: uintptr(hwnd), FnPointer: fnAddr, OutBuf: remoteOut unsafe.Offsetof(paramBlock{}.OutBuf), OutLen: 4096, } // 把结构体写进远程内存 ptrSize : unsafe.Sizeof(p) var written uintptr if err : windows.WriteProcessMemory(procHandle, remoteOut, unsafe.Pointer(p), uintptr(unsafe.Sizeof(*p)), written); err ! nil { return , err } // 把 shellcode 写在结构体后面 shellcodeAddr : remoteOut uintptr(unsafe.Sizeof(*p)) if err : windows.WriteProcessMemory(procHandle, shellcodeAddr, unsafe.Pointer(shellcodeBytes[0]), uintptr(len(shellcodeBytes)), written); err ! nil { return , err } // 5. 创建远程线程执行 threadHandle, _, err : windows.CreateRemoteThread(procHandle, nil, 0, shellcodeAddr, remoteOut, 0, nil) if err ! nil { return , err } defer windows.CloseHandle(threadHandle) windows.WaitForSingleObject(threadHandle, windows.INFINITE) // 6. 读回结果 var outBytes []byte size : 4096 * 2 err windows.ReadProcessMemory(procHandle, p.OutBuf, outBytes[0], uintptr(size), written) if err ! nil { return , err } utf16Data : unsafe.Slice((*uint16)(unsafe.Pointer(outBytes[0])), 4096) return windows.UTF16ToString(utf16Data), nil }这个骨架可以跑但有几个地方是演示用的简化写法ReadProcessMemory 那块直接往 outBytes 里写很容易崩真项目里要先分配切片再取数据指针。另外windows.CreateRemoteThread返回的错误处理在不同 Go 版本里参数个数有差异以你实际使用的 x/sys 为准。4.4 窗口筛选与控件匹配的实操经验实际使用中最大的时间消耗不是注入而是定位“哪个控件才是密码框”。我经验里几个反直觉的地方自绘控件是重灾区。Chrome 的密码框、部分网银控件、QQ 的密码输入框全是 DirectUI 或自绘。你对它们找 ES_PASSWORD 样式一个都找不到。这时候我能用的保底方案是枚举窗口树里所有能接收到键盘输入的控件对每个控件做一次WM_GETDLGCODE查询凡是返回 DLGC_WANTCHARS 且能聚焦的都拉进候选列表然后逐个尝试注入读取看哪个返回的字符串和界面显示长度对得上。双缓冲窗口的坑。有些程序把真实输入控件藏在一个“假框架”里外面套了一个自绘窗格。EnumChildWindows 默认能看到子窗口但如果外层用了 WS_EX_TRANSPARENT 或者 layered window 技巧内层控件在普通枚举里可能看不见。这时需要加上WS_EX_APPWINDOW之类的扩展样式检查并深度递归。同一进程多窗口的情况。比如浏览器有多个标签页每个标签页都有一个自己的“密码输入框”控件。工具会扫描出多个匹配项输出时必须打印窗口路径或所属 tab 标题否则用户分不清是哪一个。我在输出里加了窗口类名、窗口标题和控件坐标。5. 常见问题与排查记录5.1 ERROR_ACCESS_DENIED 与提权策略这个错误是最常见的OpenProcess 直接返回 5。我遇到过的场景目标程序在管理员权限下运行工具是普通用户权限或者目标进程是会话 0 的系统服务而你在会话 1 的桌面上运行。解决办法就是让工具也以管理员权限运行。Go 编译出来的 exe 默认没有管理员 manifest你需要在构建时加上-ldflags -Hwindowsgui或者在项目里放一个.manifest文件设置requireAdministrator。还有一条路是启用 SeDebugPrivilege但这个权限默认不在用户令牌里需要拿到管理员令牌后才能激活。实践下来最省事的还是 manifest。还有一个 Windows 11 上遇到的新坑当目标进程以“受保护的程序”方式运行时比如 SmartScreen 标记的程序即使你是管理员也打不开。这个暂时没有完美解只能给用户提示。5.2 位数不匹配导致的崩溃和空结果我在开发时踩过一个很经典的坑64 位工具注入 64 位 Shellcode 到 32 位目标进程结果远程线程刚启动就 Segmentation Fault目标进程直接被杀。后来加了位数检查func checkArch(pid uint32) error { is32Bit, err : isProcess32Bit(pid) if err ! nil { return err } if is32Bit unsafe.Sizeof(uintptr(0)) 8 { return fmt.Errorf(target is 32-bit, but this tool is 64-bit; use 386 build) } return nil }判断 64 位进程的标准姿势是IsWow64Process2或者查 PEB 的NtWow64。Go 里可以用golang.org/x/sys/windows的windows.IsWow64Process2来查。如果目标进程是 32 位而你手里只有 64 位工具最直接的做法是再交叉编译一个 386 版。另一个位数相关的坑出现在 Shellcode 内部32 位调用约定是 stdcall参数全压栈64 位是 Microsoft x64前四个参数走 RCX/RDX/R8/R9。如果 Shellcode 只写了 64 位强行往 32 位进程里跑函数参数全部错位结果就是读到的文本是乱码或者空串。5.3 Shellcode 没反应、读回空串的处理思路Shellcode 创建成功线程也创建了但读回缓冲区全是 0。别急着怀疑注入代码先看几个地方第一参数结构体里 GetWindowTextW 的地址对吗在主程序里用GetProcAddress(GetModuleHandleW(Luser32.dll), LGetWindowTextW)拿到的地址是本地进程的地址。正常情况下所有进程加载系统 DLL 时基址都是一样的ASLR 全局统一但有些系统开启强制模块随机化后不同进程同一个函数的地址可能不同。最保险的做法是从远程进程里也查一遍。怎么查很简单用 CreateRemoteThread 加载一段“迷你 Shellcode”到目标进程专门调用 GetModuleHandleW 和 GetProcAddress 拿到函数地址然后返回给主程序。这个“地址解析 Shellcode”和“读取 Shellcode”是分开的两段前者只干一件事后者才去真正读文本。第二输出缓冲区地址是否真的被写入了。GetWindowTextW 的第二个参数是缓冲区指针如果 p.OutBuf 用的是相对地址而不是绝对地址就会写到错误的虚拟地址。我在 paramBlock 里存的是remoteOut offsetof(OutBuf)的绝对地址这个没问题。但如果你图省事把本地切片的地址填进去那线程就写进本地地址空间去了而本地地址空间和远程地址空间是不通的结果当然是读不回来。第三线程退出时机。创建远程线程后立刻 ReadProcessMemory很可能线程还没执行完。所以WaitForSingleObject(INFINITE)是必须的不是可选项。我还见过有人用Sleep(1000)代替等线程这是真的不负责任代码快跑完时线程才刚起来。5.4 安全软件拦截与合规边界WriteProcessMemory CreateRemoteThread 这套组合拳是安全软件重点监控的行为。我自己在 Windows Defender 开启实时保护的情况下测试刚 create 完远程线程Defender 就弹了个可疑行为警告。如果你是在自己的实验环境、自己做技术验证可以在排除路径或者临时关闭实时保护但别指望这东西能在客户生产环境里“无声无息”地跑。对合法用途我建议两点一是工具要有明确的帮助说明和用途标注方便你解释为什么需要这么高的权限二是只对你有完全控制权的进程进行操作别引入任何“批量扫描并导出所有密码”的功能。这不是空话我在实际和同行交流时见过不少工具因为过于“功能强大”而被人拿去越权的案例。另外还有一个合规细节如果你把工具分享给队友别把 Shellcode 和参数结构体当作“加密数据”藏着掖着直接开源反而更容易被接受。社区里这类工具本身就有非常久的历史从早期的 Cain Abel、Password Spectator到后来的各种开源 PowerShell 脚本本质上用的都是同一套原理公开反而安全。6. 几个我没写进文档的细节最后分享三个平时不写在 README 里的细节。第一个是关于 Shellcode 的调试方式。汇编源文件里加一个变量debug_jump如果置 1Shellcode 在调用 GetWindowTextW 前先跳到死循环。这样用调试器附加到目标进程时能看到线程停在那里。我在排 5.3 的“读回空串”问题时全靠这个跳转定位否则无法区分是线程没执行到函数还是函数读错了对象。第二个是代码里我用了一个很小的 trick 来处理 32/64 位通用。主程序里不写死函数指针而是启动时用 x/sys 的windows.NewLazySystemDLL加载 user32 和 kernel32查一次所有要用的函数地址放进一个全局映射表。这样交叉编译 386 版本时只需重新编译Shellcode 也用对应汇编重新生成不需要改任何流程代码。第三个是关于输出安全。工具拿到明文密码后不要直接打到日志文件。我实现了一个-clip参数直接把密码复制到剪贴板然后控制台只显示“已复制”。这在处理自己的软件登录框时体验很好。但注意剪贴板的数据别的程序也能读走用完顺手再按一下 CtrlC 覆盖一下就好。这个工具前前后后我迭代了差不多两周最折腾的环节不是 Shellcode而是窗口枚举里的各种边缘情况。你要是也打算做一个我的忠告是先把“读到一个密码框”的通路跑通再去优化覆盖率和隐蔽性。系统版本、窗口框架、自绘组件这三大变量能把任何看似完美的方案搅成浆糊。先求一个能用的最小版本再慢慢加黑名单、白名单这套路永远比空想复杂场景靠谱。
返回列表