破解'nameOf'
摘要
本文解释了如何通过StackWalker和ClassFile API在运行时内省字节码,实现C#的nameof表达式的Java等价物,从而能够检索参数名称以用于异常消息。
<p><a href="https://lobste.rs/s/bcfzer/hacking_nameof">评论</a></p>
查看缓存全文
缓存时间: 2026/08/12 18:23
# 破解 'nameOf'(来源:https://committing-crimes.com/articles/2026-08-04-java-nameof/)
在验证参数时,通常最好在异常消息中包含相应的参数名称,以便立即明确哪个参数收到了错误的值:
```
public static MyObject create(String name) {
if (name.isBlank())
throw new IllegalArgumentException("name must not be blank");
// ...
}
```
然而,这里存在一个关键的重复:参数名称被硬编码进了异常消息。为了避免这种重复(以及一些更小众的使用场景),一些编程语言(如 C#)实现了 `nameof` 表达式(或类似机制),它允许以字符串形式获取标识符的文本名称。这可以防止不完整的重构静默地引入不一致。Java 没有这样的构造……
……但我们可以自己造一个!通过在运行时内省字节码,甚至可以在不借助插桩(instrumentation)或注解处理的情况下,为这个“问题”实现一个纯库解决方案。诚然,这是用一种极其过度设计的方式来解决一个小问题,但事实证明,这是一个理解 Java 字节码工作原理的有趣练习。所以,让我们来破解一个 `nameOf` 吧!
## 对抗 Java 虚拟机 (https://committing-crimes.com/articles/2026-08-04-java-nameof/#rage-against-the-java-virtual-machine)
首先,理解 JVM 的运行方式至关重要。当 Java 编译器编译 Java 源代码时,会生成 Java 字节码。字节码通常包含相当多的调试信息,这些信息可用于将字节码指令与其源码对应起来,这也是为什么在你的 IDE 中调试 Java 如此顺畅。字节码中通常存储的另一类有用信息是局部变量的名称,以及——在使用 `\-parameters` 编译时——参数名称。JVM 是一台栈式机器,本质上是读取类的字节码表示形式中的指令。它通过将参数压入操作数栈、弹出它们以执行操作,并将结果压回栈中以供后续指令使用,来执行这些指令。
## 实现 `nameOf` (https://committing-crimes.com/articles/2026-08-04-java-nameof/#implementing-nameof)
为了真正在 Java 中实现 `nameOf` 方法,我们将使用两个关键 API:`StackWalker` API 和相对较新的 `ClassFile` API。我们可以使用前者来确定调用我们 `nameOf` 方法的栈帧。该栈帧包含多项重要信息:调用类、调用方法的名称和描述符,甚至调用方法内的字节码索引。
```
Optional optCallerFrame = STACK_WALKER.walk(frames -> frames.skip(1).findFirst());
if (optCallerFrame.isEmpty())
throw new IllegalStateException("Could not find caller frame");
StackWalker.StackFrame callerFrame = optCallerFrame.get();
Class callerClass = callerFrame.getDeclaringClass();
int currentBci = callerFrame.getByteCodeIndex();
if (currentBci < 0)
throw new IllegalArgumentException("Cannot determine bytecode index of caller. Ensure the JVM is not configured to strip bytecode indices.");
```
利用这些信息,我们可以定位调用类的 `\.class` 资源,并将其解析为 `ClassModel`:
```
String classResourceName = callerClass.getName().replace('.', '/') + ".class";
byte[] classBytes;
try (InputStream is = callerClass.getClassLoader().getResourceAsStream(classResourceName)) {
classBytes = is.readAllBytes();
} catch (IOException e) {
throw new RuntimeException(e);
}
ClassModel callerClassModel = ClassFile.of().parse(classBytes);
```
这就是乐趣开始的地方!虽然通过比较名称和描述符来找到正确的方法相当简单,我们仍然需要知道方法内部字节码索引处发生了什么。为了实现这一点,我们基本上必须自己实现一台栈式机器。我们可以遍历构成调用方法方法体的指令,并模拟操作数栈。我们的模拟操作数栈不存储实际的运行时值,而是存储符号名称(例如局部变量或字段名称)。随着指令的执行,这些符号值会在模拟栈中传播,直到我们到达 `nameOf` 调用。一旦到达目标指令(它总是一个方法调用),我们就可以从模拟栈中恢复参数的符号名称。
```
int byteCodeOffset = 0;
for (CodeElement element : codeModel.elementList()) {
if (element instanceof Instruction instruction) {
switch (instruction) {
case FieldInstruction field -> {
String name = field.name().stringValue();
int i = name.indexOf('$');
if (i != -1)
name = name.substring(i + 1);
switch (field.opcode()) {
case GETSTATIC -> stack.push(new StackValue(name));
case GETFIELD -> {
stack.pop();
stack.push(new StackValue(name));
}
case PUTSTATIC -> stack.pop();
case PUTFIELD -> {
stack.pop();
stack.pop();
}
default -> throw new RuntimeException("Unknown FIELD_ACCESS opcode: " + field.opcode());
}
}
case InvokeInstruction invoke -> {
MethodTypeDesc desc = invoke.typeSymbol();
/*
* Pop the arguments in reverse order from the operand stack, to that args.getFirst() ends up
* being the *first* parameter passed to the invoked method.
*/
int paramCount = desc.parameterCount();
List args = new ArrayList<>(Collections.nCopies(paramCount, null));
for (int i = paramCount - 1; i >= 0; i--) {
args.set(i, stack.pop());
}
String methodName = invoke.method().name().stringValue();
if (invoke.method().owner().name().equalsString("com/example/nameof/Name") &&
methodName.equals("nameOf") &&
byteCodeOffset == currentBci
) {
if (args.isEmpty()) {
throw new IllegalArgumentException("nameOf() call has no argument");
}
return args.getFirst().s;
}
if (!desc.returnType().equals(ConstantDescs.CD_void)) {
stack.push(new StackValue(""));
}
}
case LoadInstruction load -> stack.push(new StackValue(localVariables.get(load.slot())));
case StoreInstruction _ -> stack.pop();
// ... Handle all other instructions
default -> throw new IllegalStateException("Unexpected instruction: " + instruction);
}
byteCodeOffset += instruction.sizeInBytes();
}
}
```
## 结果 (https://committing-crimes.com/articles/2026-08-04-java-nameof/#the-result)
将所有内容组合在一起后,我们最终得到了一个 `nameOf` 方法,它允许我们以*极小*的代价去除一点重复——而代价是一个极其不稳定且可能向前不兼容的实现!
```
public static MyObject create(String name) {
if (name.isBlank())
throw new IllegalArgumentException(nameOf(name) + " must not be blank");
// ...
}
```
```
jshell> create("");
java.lang.IllegalArgumentException: name must not be blank
```
### 我真的应该使用这个吗?(https://committing-crimes.com/articles/2026-08-04-java-nameof/#should-i-actually-use-this)
**不!** 还不相信吗?虽然代码看起来运行正常,但它在很大程度上未经测试。即使经过了测试,它也极易被破坏。每一个影响操作数栈的新操作码都可能以不可预知的方式微妙地破坏此实现。可能的故障从抛出异常到返回错误结果不等。此实现无法在不同的 JVM 配置和编译器实现之间可靠运行,因为后者可能会剥离此实现所依赖的可选调试符号。简而言之,**不要使用这个。**
完整源代码可以在这里找到(https://github.com/CommittingCrimes/2026-08-04-java-nameof)。
相似文章
为什么是0xCAFEBABE?
本文内容基于1996年的一次新闻组讨论,探讨了Java类文件魔数0xCAFEBABE的起源和原因。
On Hacking
Richard Stallman recounts a playful hack using six chopsticks to illustrate the true meaning of hacking as playful cleverness and exploration, and traces the origins of hacker culture at MIT.
在 C 中调用一个函数而不给它命名
一场关于在 C 语言中不直接命名就调用函数的技术讨论,涵盖底层语言技巧以及编译器和链接器的行为。
字节码虚拟机在意外场景中的应用 (2024)
本文探讨了字节码虚拟机的出人意料的应用,特别是Linux内核中的eBPF以及编译后二进制文件中用于调试信息的DWARF表达式。
标签名中的奥秘?原来是JavaScript
PortSwigger的研究文章展示了利用HTML标签名中的JavaScript属性进行新的XSS攻击向量,使WAF绕过在不同浏览器中成为可能。