Skip to content

fix: checksum opcodes which include constant pool - #393

Merged
algomaster99 merged 1 commit into
mainfrom
fix/hash-whole-classfile
Oct 18, 2026
Merged

algomaster99 merged 1 commit into
mainfrom
fix/hash-whole-classfile

Conversation

@algomaster99

@algomaster99 algomaster99 commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Summary

HashComputer.computeHash did not checksum bytecode. It renamed every CONSTANT_Class entry to Bar, concatenated the UTF8 entries of the constant pool, sorted the characters, and hashed the result. Opcodes, numeric constants and referenced classes were therefore not part of the checksum. For example, replacing iadd with isub or new NoSuchMethodError with new NoSuchFieldError did not change the hash.

This PR computes the checksum over a canonical listing of the class instead:

  • the class header, fields, and every method instruction with its symbolic operands (owner, name, descriptor, constants, jump targets);
  • only the class's own name is replaced by a constant (<this>); references to other classes are kept;
  • fields and methods are sorted, while instruction order within a method is kept;
  • debug information and stack map frames are dropped;
  • for classes extending java.lang.reflect.Proxy, each Method field is named after the block in <clinit> that initializes it, and those blocks are sorted. This covers the JDK numbering and ordering proxy members non-deterministically across runs.

All callers (JDK, supply-chain and runtime indexers, and the watchdog) go through computeHash, so no call sites change. asm-commons is added for ClassRemapper; it is relocated into the agent jars together with the rest of ASM.

Examples

Hashes below are the first 12 hex characters, computed with main (old) and this PR (new).

Plain classes

public class Calc {
    static int LIMIT = 10;
    int sum(int a, int b) { return a + b; }
    boolean ok(int x) { return x < LIMIT; }
}

The canonical listing that gets hashed:

class 33 <this> extends java/lang/Object implements
field 8 LIMIT I = null
method 8 <clinit>()V throws
  BIPUSH 10
  PUTSTATIC <this>.LIMIT I
  RETURN
method 1 <init>()V throws
  ALOAD 0
  INVOKESPECIAL java/lang/Object.<init>()V false
  RETURN
method 0 ok(I)Z throws
  ILOAD 1
  GETSTATIC <this>.LIMIT I
  IF_ICMPGE 5
  ICONST_1
  GOTO 6
  ICONST_0
  IRETURN
method 0 sum(II)I throws
  ILOAD 1
  ILOAD 2
  IADD
  IRETURN
Variant Change Old New
Calc original 4f8635728b08 5d9c13db4a42
Calc a + b → a - b 4f8635728b08 ❌ same c90fb32edcee ✅ differs
Calc LIMIT = 10 → LIMIT = 99 4f8635728b08 ❌ same 89e7bf652e4e ✅ differs
Calc fields and methods declared in another order 4f8635728b08 ✅ same 5d9c13db4a42 ✅ same
Io return new java.io.File("x") b3a7eee4e8cd 5348696fa0e4
Io File → java.lang.String b3a7eee4e8cd ❌ same bcc3ccadca37 ✅ differs
Renamed same code as Io, different class name b3a7eee4e8cd ✅ same 5348696fa0e4 ✅ same

Proxy classes

$Proxy9 and $Proxy13 (test fixtures) are JDK proxies for picocli.CommandLine$Command from two runs. They are identical except for the numbering of their 48 Method fields:

$Proxy9                                     $Proxy13
public final boolean helpCommand() {        public final boolean helpCommand() {
    getstatic m32                                getstatic m29
    ...                                          ...
static {                                    static {
    ldc "picocli.CommandLine$Command"            ldc "picocli.CommandLine$Command"
    invokestatic Class.forName                   invokestatic Class.forName
    ldc "helpCommand"                            ldc "helpCommand"
    iconst_0                                     iconst_0
    anewarray Class                              anewarray Class
    invokevirtual Class.getMethod                invokevirtual Class.getMethod
    putstatic m32                                putstatic m29

The instructions before putstatic are the same in both, so m32 and m29 are both renamed to m + the first 16 hex characters of their SHA-256: m2144dd7554349c1d. After renaming and sorting the <clinit> blocks, the two canonical listings (~1,400 lines each) are identical.

Class Old New
$Proxy9 8378f33de012 144bbc0de4dc
$Proxy13 (different field numbering) 8378f33de012 ✅ same 144bbc0de4dc ✅ same
$Proxy9 with helpCommand() and mixinStandardHelpOptions() reading each other's Method field 8378f33de012 ❌ same fc8e50516ed2 ✅ differs

In the last row, each call reaches the InvocationHandler as the other method. The old hash cannot see this because it only counts which bytes are in the constant pool, and the names m0–m47 are the same. (The swap was made with ASM using new ClassWriter(reader, 0); an unmodified round trip through ASM leaves both hashes unchanged.)

Tests

HashComputerTest adds 5 tests:

  • proxies that differ only in field numbering ($Proxy9 vs $Proxy13 fixtures) have the same hash;
  • a proxy with reordered fields, methods and <clinit> blocks has the same hash;
  • changing one opcode, one integer constant, or one referenced class changes the hash.

The existing GCA equality tests go through HashComputer and still pass. mvn -pl terminator-commons test: 25 tests, 0 failures.

Hashing the whole JDK works on 21.0.2 (25,309 classes) and 25.0.2 (24,716 classes), and two runs give identical output.

Verification steps (manual, before marking ready)

  • mvn install -DskipTests (version is now 0.14.2-SNAPSHOT)
  • In experiments/rq3_rq4/{pdfbox,ttorrent,graphhopper}, update generate-index.sh and sbom.exe.sh, which still point to 0.13.1-SNAPSHOT jars

Will be done later after each improvements.

  • Regenerate every bomi.jsonl. All hashes change, so old BOMIs report every class as [MODIFIED].
  • PDFBox: run sbom.exe.sh; expect no [NOT ALLOWLISTED] or [MODIFIED]
  • Ttorrent: run sbom.exe.sh; expect no [NOT ALLOWLISTED] or [MODIFIED]
  • GraphHopper: run sbom.exe.sh; expect only the UUID-named DropwizardResourceConfig$SpecificBinder… classes
  • Regenerate one BOMI twice and check the output is identical
  • Watch for any new [MODIFIED] on $Proxy* or GeneratedConstructorAccessor* classes. That would mean a source of non-determinism the fixtures don't cover.

Out of scope (follow-ups)

  • Annotations, generic signatures, and inner-class/nest/record attributes are not hashed. A class that changes only one of those keeps its hash.
  • MethodParameters is dropped along with the rest of the debug information.
  • The watchdog fails open. If computeHash throws inside transform(), the JVM ignores the exception and loads the class unchanged.
  • ASM 9.8 reads classfiles up to Java 25, so JDK 26+ needs an ASM upgrade.
  • Spotless (palantir-java-format) fails on JDK 25; formatting was applied with JDK 21.

🤖 Generated with Claude Code

@algomaster99 algomaster99 changed the title fix: checksum the bytecode instead of the sorted constant pool fix: checksum opcodes which include constant pool Sep 29, 2026
The checksum used to be computed over the sorted characters of the UTF8
constant-pool entries, after renaming every class reference. Opcodes,
numeric constants and referenced classes were therefore ignored.

The checksum now covers every instruction with its symbolic operands.
Only the class's own name is replaced, and fields and methods are sorted.
For proxy classes, the Method fields are named after their initializer
and the initializer blocks are sorted, because the JDK numbers and
orders them non-deterministically.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@algomaster99
algomaster99 force-pushed the fix/hash-whole-classfile branch from e32ecdb to 8971a29 Compare September 29, 2026 09:10
@algomaster99
algomaster99 marked this pull request as ready for review October 18, 2026 12:13
@algomaster99

Copy link
Copy Markdown
Member Author

All class hashes in the BOMI will change because of this new hash algorithm. But this change was important since we claim this in the paper.

@algomaster99
algomaster99 merged commit 726f3e0 into main Oct 18, 2026
8 checks passed
@algomaster99
algomaster99 deleted the fix/hash-whole-classfile branch October 18, 2026 12:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant