This project exists to answer a security question: does a specific JAR file, as acquired from a package manager or other location, contain Java class files which exactly match those which would be generated by compiling the original source?
It is designed identify attack wearing a project release manager has issued a subtly tampered release. In some open source projects a committer volunteers to become release manager. We then a release branch, build artifact binaries off that, sign them and publish them for direct download and via Maven Central.
It is straightforward to verify that a tarball of source files matches the git branch of the release, by untarring the file then using diff -rq:
diff -rq downloads/untar/source/hadoop-3.5.0-src ../apache-hadoopWhat is not so straightforward is a binary comparison as different compilers may produce different artifacts.
We are lucky here in that hadoop has two parallel built releases, x86 and arm, which can and should be compared to see how they vary.
Full analysis of the hadoop 3.4.3 x86 and aarch64 distributions.
Claude,when javac compilers compile java source, do they always produce byte for byte identical .class files
No. Java compilers are not guaranteed to produce byte-for-byte identical .class files across different runs or environments. Several factors can cause differences:
Across different compilers/versions:
- Different javac versions (e.g., JDK 11 vs JDK 17) can emit different bytecode, optimize differently, or use different constant pool ordering.
- Different compiler implementations (e.g., Oracle javac vs Eclipse ECJ) produce structurally equivalent but binary-different output.
Even with the same compiler version:
- Constant pool ordering — the JLS and JVM spec don't mandate a specific order for constant pool entries. In practice, javac from the same JDK
version tends to be deterministic for the same input, but this is an implementation detail, not a guarantee.
- Attribute ordering — debug info, annotations, and other attributes may vary.
- Timestamps — .class files themselves don't contain timestamps, but JAR files do (in ZIP entry headers), which is why reproducible builds focus
heavily on JAR packaging.
In practice:
- The same javac version, on the same OS, with the same source files, same classpath, and same flags will typically produce identical .class files.
Most modern javac implementations are deterministic given identical inputs.
- But this is a de facto behavior, not a spec guarantee. The JVM specification defines the semantics of class files, not the exact binary encoding
compilers must produce.
Even if javac builds were consistent across all house machines are using the same version, this is not a practical way to verify that .class files are safe, not without setting up a build host/container with the same java version.
This tool proposes to assist auditing with a goal of verifying that compiled artifacts are "close enough".
A simple first pass would be just to verify that for a given .class file, there are no difference in the number of methods, their names and their signatures.
- This is a kotlin project, built with gradle
- tests are junit 6
- logging is slf4J api with a log4j 2 back end
| path | provenance |
|---|---|
| data/good/log4j-1.2.17-cloudera.jar | cloudera sanitized |
| data/bad/log4j-1.2.17.jar | original and insecure |
Cloudera ship a version of log4J 1.2, log4j-1.2.17-cloudera.jar, which removes the CVE vulnerabilities
| Metric | Value |
|---|---|
| Reference classes | 315 |
| Target classes | 314 |
| Differences | 2 |
| class | change | symbol |
|---|---|---|
org/apache/log4j/FilteredObjectInputStream |
missing in target | |
org/apache/log4j/net/SocketNode |
method removed | getAllowedClasses()Ljava/util/Collection; |
This is the interesting one, which drove me to create the library. Historically we've always put the x86 artifacts up on maven, built on the x86 container, generally running within a linux VM on AWS or other cloud infrastructure. The Arm64 binaries were only ever shared as a .tar.gz file.
For the 3.4.3 release, the AWS Nexus server seemed to generate multiple staging repositories from a single build, which was suggested as to be related to the work VPN within which the EC2 VMs were hosted.
The solution here was to make the arm64 release the -asfrelease build which published to maven, as that was running on on local multicore high performance unix workstation (a raspberry pi5), rather than cloud infra, so there was no VPN to interfere.
And it worked!
java -jar build/libs/auditor-1.0-SNAPSHOT-all.jar -format markdown data/good/hadoop-common-3.4.3.jar data/bad/hadoop-common-3.4.3.jar
Reference: data/good/hadoop-common-3.4.3.jar (MD5: a0eb52d25c35c2cd95d8147e7cffdf48)
Target: data/bad/hadoop-common-3.4.3.jar (MD5: c0c4819f052220f6caf9ba86464f2588)
MD5 checksums differ. Performing structural comparison...
| Metric | Value |
|---|---|
| Reference classes | 2640 |
| Target classes | 2640 |
| Differences | 9 |
| class | change | symbol |
|---|---|---|
org/apache/hadoop/ipc/protobuf/ProtobufRpcEngineProtos$RequestHeaderProto |
superclass changed | com/google/protobuf/GeneratedMessage -> com/google/protobuf/GeneratedMessage$ExtendableMessage |
org/apache/hadoop/ipc/protobuf/ProtobufRpcEngineProtos$RequestHeaderProto |
method removed | <init>(Lcom/google/protobuf/GeneratedMessage$Builder;)V |
org/apache/hadoop/ipc/protobuf/ProtobufRpcEngineProtos$RequestHeaderProto |
method removed | <init>(Lcom/google/protobuf/GeneratedMessage$Builder;Lorg/apache/hadoop/ipc/protobuf/ProtobufRpcEngineProtos$1;)V |
org/apache/hadoop/ipc/protobuf/ProtobufRpcEngineProtos$RequestHeaderProto |
method added | <init>(Lcom/google/protobuf/GeneratedMessage$ExtendableBuilder;)V |
org/apache/hadoop/ipc/protobuf/ProtobufRpcEngineProtos$RequestHeaderProto |
method added | <init>(Lcom/google/protobuf/GeneratedMessage$ExtendableBuilder;Lorg/apache/hadoop/ipc/protobuf/ProtobufRpcEngineProtos$1;)V |
org/apache/hadoop/ipc/protobuf/ProtobufRpcEngineProtos$RequestHeaderProto$Builder |
superclass changed | com/google/protobuf/GeneratedMessage$Builder -> com/google/protobuf/GeneratedMessage$ExtendableBuilder |
org/apache/hadoop/ipc/protobuf/ProtobufRpcEngineProtos$RequestHeaderProto$Builder |
method added | clone()Lcom/google/protobuf/GeneratedMessage$ExtendableBuilder; |
org/apache/hadoop/ipc/protobuf/ProtobufRpcEngineProtos$RequestHeaderProto$Builder |
method added | clear()Lcom/google/protobuf/GeneratedMessage$ExtendableBuilder; |
org/apache/hadoop/ipc/protobuf/ProtobufRpcEngineProtos$RequestHeaderProtoOrBuilder |
interfaces changed | [com/google/protobuf/MessageOrBuilder] -> [com/google/protobuf/GeneratedMessage$ExtendableMessageOrBuilder] |
This highlights that
- protoc is behaving differently on the different images. (update: it is not; some copied in classes in src/arm was out of sync...auditor found this)
- the tar.gz. images created are potentially incompatible at a binary level, that is: the jars must not be mixed.
I'm confident here the release manager (me) wasn't malicious, but it does show that the different docker images for x86 and arm are resulting in different code. This is not good.
Claude, would it be possible to compare java assembly codes to look for different code sequences within a method, particularly if classes were built with debugging
symbols
Yes, absolutely. ASM already gives us everything we need — we're currently using SKIP_CODE which discards the method bodies. Removing that flag would let us visit the actual bytecode instructions.
What's feasible
Instruction sequence comparison — ASM's MethodVisitor receives every bytecode instruction (visitInsn, visitMethodInsn, visitFieldInsn, visitLdcInsn, etc.). We
could capture the opcode sequence for each method and compare them between reference and target.
Debug symbol differences — These are the main source of "legitimate" differences between otherwise identical compilations:
- Line number tables (visitLineNumber) — map bytecode offsets to source lines. Different compilers or compiler flags (-g vs -g:none) produce different tables, or
omit them entirely.
- Local variable tables (visitLocalVariable) — variable names, types, and scope ranges. Again, only present with debug info.
- Source file attribute — visitSource gives the filename.
Practical approach for auditing:
1. Compare instructions, ignore debug metadata — Strip line numbers and local variable tables before comparing. This way, a release built with -g:none would still
match one built with -g:lines,vars,source as long as the actual code is the same.
2. Normalize constant pool references — Instructions reference constants by index. ASM abstracts this away (you get the actual string/type/value), so comparisons
would work across different constant pool orderings.
3. Handle compiler-generated differences — Even with the same source, different javac versions may:
- Reorder switch table entries
- Use different string concatenation strategies (StringBuilder vs invokedynamic)
- Generate different bridge methods for generics
- Use different local variable slot assignments
A tiered approach makes sense:
- Level 1 (current): structural — method/field signatures, class hierarchy
- Level 2: bytecode — compare instruction sequences per method (ignoring debug info)
- Level 3: semantic — flag specific suspicious patterns (e.g., new network calls, reflection, Runtime.exec(), class loading)
Level 3 would be the most valuable for the threat model — a tampered release would likely inject code that calls out to external services or executes commands. We
could scan for specific opcodes/method references that are suspicious when they appear in the target but not the reference.
... some minutes later ...
⏺ Done. Here's what was added:
AuditLevel enum with values 1 (structural), 2 (bytecode), 3 (semantic — placeholder for now).
Level 2 implementation:
- ClassFileParser now accepts a level. At level 2+, it reads method bodies instead of skipping them, capturing a normalized instruction list per method (opcode + operands, no debug info like line numbers or local variable names).
- ClassComparator at level 2 compares instruction sequences for methods that exist in both JARs. Different bytecode triggers BYTECODE_CHANGED.
- Jump labels are intentionally excluded from the comparison (just the opcode), so label renumbering doesn't cause false positives.
Usage: java -jar build/libs/auditor-1.0-SNAPSHOT-all.jar -level 2 -format csv reference.jar target.jar
✻ Cogitated for 5m 3s
... some minutes later ...
Level 3 is implemented. Here's what it does:
SuspiciousPatterns scans bytecode instructions for calls to dangerous APIs, categorized as:
| Category | Examples |
|---|---|
| process execution | Runtime.exec, ProcessBuilder |
| network access | Socket, ServerSocket, URL.openConnection, HttpURLConnection |
| reflection | Class.forName, Method.invoke, Field.set |
| class loading | ClassLoader.loadClass, ClassLoader.defineClass, URLClassLoader |
| native code | System.loadLibrary, Runtime.load |
| cryptography | Cipher, KeyPairGenerator |
| environment access | System.getenv, System.getProperty |
| script execution | ScriptEngine.eval |
| deserialization | ObjectInputStream.readObject |
| thread creation | Thread.start |
```
Key design decisions:
- Only flags suspicious calls that are new in the target — calls already present in the reference are not flagged (they're part of the original code)
- Scans both added methods (all instructions) and modified methods (only newly added suspicious calls)
- Level 3 includes all level 2 checks (bytecode comparison) plus the semantic analysis
Usage: java -jar build/libs/auditor-1.0-SNAPSHOT-all.jar -level 3 -format markdown reference.jar target.jar
✻ Worked for 2m 20s
Most of this has been written by AI so uncopyrightable.