返回

JDK23: java.lang.foreign Native Hook 键码捕获难题及解决

windows

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 结构体的内存布局,并通过 MemorySegmentValueLayoutlParam 指向的内存中读取结构体成员的值。

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成员的值,这个值就是按键的虚拟键码。
  • 操作步骤:
    1. 编译代码。
    2. 执行编译后的 KeyHook 类, 你应该能在控制台中看到你所按下键盘按键的虚拟键码。

解决方案二: 使用 Win32 APIs 提供的LowLevelKeyboardProc 函数类型定义。

java.lang.foreign API 可以将 Windows API 里的 函数指针和类型,使用Foreign Function Interface 来。我们可以根据Win32 API 的声明文档来定义 LowLevelKeyboardProc 函数指针。利用它直接完成 lParamKBDLLHOOKSTRUCT 的转换


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的参数使用逻辑。
  • 操作步骤:
    1. 编译代码。
    2. 运行代码,观察控制台输出的按键码,是否可以正常获取到虚拟键码

安全提示

在使用 java.lang.foreignnative hook 功能时,请注意如下几点:

  1. 内存安全 : 手动处理内存布局或结构体指针容易引发错误。要认真检查偏移量和大小是否正确,避免访问无效的内存。 使用 FunctionDescriptor 可以降低这一部分的安全隐患。
  2. 钩子泄露: 确保在程序退出前释放掉所设置的钩子,防止资源泄露。 可以利用Java的shutdown hook 在退出的时候卸载钩子。
  3. 最小权限原则 程序应该仅拥有执行所需功能的权限。如果应用程序不一定需要hook 鼠标键盘操作, 则应尽可能避免使用它们。

两种解决方案都能有效解决使用 java.lang.foreign 实现键盘 hook 时 lParam 值不正确的问题。建议使用第二种解决方案,因为它通过使用类型描述符FunctionDescriptor简化了内存访问逻辑, 并通过 java.lang.foreign 提供了更佳的类型安全性。