JDK23: java.lang.foreign Native Hook 键码捕获难题及解决
2025-01-12 17:13:34
JDK23 中使用 java.lang.foreign
进行 Native Hook 的问题与解决
在 Java 23 中, java.lang.foreign
API 为我们提供了直接与本地代码交互的新方式, 这也包括了进行 Native Hook 的可能性。 结合 SetWindowsHookEx
,可以监听用户输入等事件。但是,开发中会遇到一个问题:使用java.lang.foreign
实现的键盘 hook,捕获的lParam
值可能并非预期的按键信息。这个问题会导致每次运行程序获得的参数相同,或根本无法捕获不同的按键事件。
问题分析
主要原因是参数 lParam
的解读方式不对。SetWindowsHookEx
传递给 hookProc
函数的 lParam
实际上是一个指向 KBDLLHOOKSTRUCT
结构的指针,这个结构体包含了按键的详细信息,例如虚拟键码(Virtual-Key Code),扫描码(Scan Code),按键标志等。
直接把这个内存地址作为 long
类型的值打印,就无法得到正确的键码信息, 而每次获得的只是一个内存地址值。我们需要通过 java.lang.foreign
来正确解析这个内存结构,并读取其中包含的 vkCode
。
解决方案一: 手动解析KBDLLHOOKSTRUCT
结构体
一种解决方案是手动定义 KBDLLHOOKSTRUCT
结构体的内存布局,并通过 MemorySegment
和 ValueLayout
从 lParam
指向的内存中读取结构体成员的值。
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
public class KeyHook {
private static final int WH_KEYBOARD_LL = 13;
private static final int WM_KEYDOWN = 0x0100;
private static long hook;
private static MethodHandle callNextHookEx;
// 定义 KBDLLHOOKSTRUCT 的结构布局
private static final GroupLayout KBDLLHOOKSTRUCT_LAYOUT = MemoryLayout.structLayout(
ValueLayout.JAVA_INT.withName("vkCode"),
ValueLayout.JAVA_INT.withName("scanCode"),
ValueLayout.JAVA_INT.withName("flags"),
ValueLayout.JAVA_INT.withName("time"),
ValueLayout.JAVA_LONG.withName("dwExtraInfo")
);
public static void main(String[] args) throws Throwable {
System.loadLibrary("user32");
Linker linker = Linker.nativeLinker();
SymbolLookup user32Lookup = SymbolLookup.loaderLookup();
MethodHandle setWindowsHookEx = linker.downcallHandle(user32Lookup.find("SetWindowsHookExA").orElseThrow(), FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_INT));
callNextHookEx = linker.downcallHandle(user32Lookup.find("CallNextHookEx").orElseThrow(), FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG));
MethodHandle getMessage = linker.downcallHandle(user32Lookup.find("GetMessageA").orElseThrow(), FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT));
MethodHandle unhookWindowsHookEx = linker.downcallHandle(user32Lookup.find("UnhookWindowsHookEx").orElseThrow(), FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG));
MethodHandle hookProcHandle = MethodHandles.lookup().findStatic(KeyHook.class, "hookProc", MethodType.methodType(long.class, int.class, long.class, long.class));
MemorySegment hookProcAddress = linker.upcallStub(hookProcHandle, FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG), Arena.ofAuto());
hook = (long) setWindowsHookEx.invoke(WH_KEYBOARD_LL, hookProcAddress, MemorySegment.NULL, 0);
if (hook == 0) {
System.out.println("Failed to set hook");
return;
}
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
if (hook != 0)
unhookWindowsHookEx.invoke(hook);
} catch (Throwable t) {
System.err.println(t.getMessage());
}
}));
try(Arena arena = Arena.ofAuto()){
MemorySegment msg = arena.allocate(28);
while ((int)getMessage.invoke(msg,MemorySegment.NULL,0,0) != 0){}
}
}
public static long hookProc(int code, long wParam, long lParam) {
if (code >= 0) {
if (wParam == WM_KEYDOWN) {
// 将 lParam 解释为指向 KBDLLHOOKSTRUCT 的指针
MemorySegment structPtr = MemorySegment.ofAddress(lParam).reinterpret(KBDLLHOOKSTRUCT_LAYOUT.byteSize());
// 从结构体中获取 vkCode
int vkCode = structPtr.get(ValueLayout.JAVA_INT, KBDLLHOOKSTRUCT_LAYOUT.byteOffset(MemoryLayout.PathElement.groupElement("vkCode")));
System.out.println("Virtual Key Code: " + vkCode);
}
}
try {
return (long) callNextHookEx.invoke(hook, code, wParam, lParam);
} catch (Throwable t) {
System.err.println(t.getMessage());
return 0;
}
}
}
- 原理: 这段代码首先定义了
KBDLLHOOKSTRUCT
结构的内存布局KBDLLHOOKSTRUCT_LAYOUT
。当捕获到WM_KEYDOWN
消息时,将lParam
转换为指向KBDLLHOOKSTRUCT
的内存指针。之后使用structPtr.get(ValueLayout.JAVA_INT, ...)
获取结构体中vkCode
成员的值,这个值就是按键的虚拟键码。 - 操作步骤:
- 编译代码。
- 执行编译后的
KeyHook
类, 你应该能在控制台中看到你所按下键盘按键的虚拟键码。
解决方案二: 使用 Win32 APIs 提供的LowLevelKeyboardProc
函数类型定义。
java.lang.foreign
API 可以将 Windows API 里的 函数指针和类型,使用Foreign Function Interface
来。我们可以根据Win32 API 的声明文档来定义 LowLevelKeyboardProc
函数指针。利用它直接完成 lParam
到 KBDLLHOOKSTRUCT
的转换
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
public class KeyHookFunctionDescriptor {
private static final int WH_KEYBOARD_LL = 13;
private static final int WM_KEYDOWN = 0x0100;
private static long hook;
private static MethodHandle callNextHookEx;
// 使用 FunctionDescriptor LowLevelKeyboardProc 函数类型
private static final FunctionDescriptor LowLevelKeyboardProc = FunctionDescriptor.of(
ValueLayout.JAVA_LONG,
ValueLayout.JAVA_INT,
ValueLayout.JAVA_LONG,
ValueLayout.JAVA_LONG
);
public static void main(String[] args) throws Throwable {
System.loadLibrary("user32");
Linker linker = Linker.nativeLinker();
SymbolLookup user32Lookup = SymbolLookup.loaderLookup();
MethodHandle setWindowsHookEx = linker.downcallHandle(user32Lookup.find("SetWindowsHookExA").orElseThrow(), FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_INT));
callNextHookEx = linker.downcallHandle(user32Lookup.find("CallNextHookEx").orElseThrow(), FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG));
MethodHandle getMessage = linker.downcallHandle(user32Lookup.find("GetMessageA").orElseThrow(), FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT));
MethodHandle unhookWindowsHookEx = linker.downcallHandle(user32Lookup.find("UnhookWindowsHookEx").orElseThrow(), FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG));
MethodHandle hookProcHandle = MethodHandles.lookup().findStatic(KeyHookFunctionDescriptor.class, "hookProc", MethodType.methodType(long.class, int.class, long.class, long.class));
MemorySegment hookProcAddress = linker.upcallStub(hookProcHandle, LowLevelKeyboardProc, Arena.ofAuto());
hook = (long) setWindowsHookEx.invoke(WH_KEYBOARD_LL, hookProcAddress, MemorySegment.NULL, 0);
if (hook == 0) {
System.out.println("Failed to set hook");
return;
}
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
if (hook != 0)
unhookWindowsHookEx.invoke(hook);
} catch (Throwable t) {
System.err.println(t.getMessage());
}
}));
try (Arena arena = Arena.ofAuto()) {
MemorySegment msg = arena.allocate(28);
while ((int) getMessage.invoke(msg, MemorySegment.NULL, 0, 0) != 0) {
}
}
}
public static long hookProc(int code, long wParam, long lParam) {
if (code >= 0) {
if (wParam == WM_KEYDOWN) {
MemorySegment structPtr = MemorySegment.ofAddress(lParam);
int vkCode = structPtr.get(ValueLayout.JAVA_INT, 0);
System.out.println("Virtual Key Code: " + vkCode);
}
}
try {
return (long) callNextHookEx.invoke(hook, code, wParam, lParam);
} catch (Throwable t) {
System.err.println(t.getMessage());
return 0;
}
}
}
- 原理: 此方法使用
FunctionDescriptor
直接声明了符合Win32 API定义的函数签名。java.lang.foreign
会帮我们处理类型转换。 关键在于linker.upcallStub
调用, 此方法允许传递LowLevelKeyboardProc
,让java.lang.foreign
框架帮助我们自动转换lParam
类型到合适的KBDLLHOOKSTRUCT
指针。从而简化hookProc
的参数使用逻辑。 - 操作步骤:
- 编译代码。
- 运行代码,观察控制台输出的按键码,是否可以正常获取到虚拟键码
安全提示
在使用 java.lang.foreign
和 native hook
功能时,请注意如下几点:
- 内存安全 : 手动处理内存布局或结构体指针容易引发错误。要认真检查偏移量和大小是否正确,避免访问无效的内存。 使用
FunctionDescriptor
可以降低这一部分的安全隐患。 - 钩子泄露: 确保在程序退出前释放掉所设置的钩子,防止资源泄露。 可以利用Java的
shutdown hook
在退出的时候卸载钩子。 - 最小权限原则 程序应该仅拥有执行所需功能的权限。如果应用程序不一定需要hook 鼠标键盘操作, 则应尽可能避免使用它们。
两种解决方案都能有效解决使用 java.lang.foreign
实现键盘 hook 时 lParam
值不正确的问题。建议使用第二种解决方案,因为它通过使用类型描述符FunctionDescriptor
简化了内存访问逻辑, 并通过 java.lang.foreign
提供了更佳的类型安全性。