The class file, byte by byte
A compiled Java class keeps every method name, field name, type signature and string literal in a structure designed to be read. That is why you can analyse a JAR you have no source for — and why the first useful question about an unfamiliar WAR can be answered with eight bytes.
01What the format guarantees
The JVM specification fixes the class file layout completely. Every field is big-endian, every length is explicit, and there is no alignment padding anywhere. A parser never has to guess.
The structure, in specification order:
ClassFile { u4 magic; // 0xCAFEBABE u2 minor_version; u2 major_version; u2 constant_pool_count; cp_info constant_pool[constant_pool_count-1]; // 1-indexed u2 access_flags; u2 this_class; // index into constant_pool u2 super_class; u2 interfaces_count; u2 interfaces[interfaces_count]; u2 fields_count; field_info fields[fields_count]; u2 methods_count; method_info methods[methods_count]; u2 attributes_count; attribute_info attributes[attributes_count]; }
Two consequences matter for security work. First, names survive
compilation. javac does not strip or mangle identifiers;
obfuscators do that afterwards, as a separate step. Second, the constant pool is
a flat, indexable table, so a tool can enumerate every type a class references
without executing anything.
02The ten-byte header
Everything before the constant pool fits in ten bytes and answers the question you most often need answered.
| Offset | Bytes | Field | Meaning |
|---|---|---|---|
| 0x00 | ca fe ba be | magic | Fixed. A file that does not start with this is rejected before parsing. |
| 0x04 | 00 00 | minor_version | 0, except for preview features, which set it to 0xFFFF. |
| 0x06 | 00 41 | major_version | 0x41 = 65 = Java 21. The byte you actually want. |
| 0x08 | 00 3b | constant_pool_count | 59, so there are 58 real entries — the count is one more than the number of entries. |
The version mapping
major_version = java_version + 44. There is no exception to this
and it has held since Java 1.1.
| Java | Major | Hex | Java | Major | Hex |
|---|---|---|---|---|---|
| 1.4 | 48 | 0x30 | 17 | 61 | 0x3D |
| 5 | 49 | 0x31 | 18 | 62 | 0x3E |
| 6 | 50 | 0x32 | 19 | 63 | 0x3F |
| 7 | 51 | 0x33 | 20 | 64 | 0x40 |
| 8 | 52 | 0x34 | 21 | 65 | 0x41 |
| 11 | 55 | 0x37 | 24 | 68 | 0x44 |
$ unzip -p app.war WEB-INF/classes/com/example/Foo.class | xxd -l 8 00000000: cafe babe 0000 0034 .......4 ^^ 0x34 = 52 = Java 8 # Or arithmetically, straight out of the stream: $ unzip -p app.war WEB-INF/classes/com/example/Foo.class \ | dd bs=1 skip=6 count=2 2>/dev/null | xxd -p 0034
This tells you the target the class was compiled for, which bounds the language features in play and tells you which runtime can load it. It does not tell you which JDK ran the compiler.
03The constant pool
Every name, signature, string literal and cross-class reference lives in one table. Entries refer to each other by index, so a single method call is spread across three or four entries.
| Tag | Type | Payload |
|---|---|---|
| 1 | Utf8 | u2 length, then modified-UTF8 bytes. Every actual string lives here |
| 3 | Integer | u4 |
| 4 | Float | u4 |
| 5 | Long | u8 — takes two pool slots |
| 6 | Double | u8 — takes two pool slots |
| 7 | Class | u2 index to a Utf8 holding the internal name |
| 8 | String | u2 index to a Utf8 |
| 9 | Fieldref | u2 Class index, u2 NameAndType index |
| 10 | Methodref | u2 Class index, u2 NameAndType index |
| 11 | InterfaceMethodref | as Methodref |
| 12 | NameAndType | u2 name index, u2 descriptor index |
| 15 | MethodHandle | u1 reference kind, u2 reference index |
| 18 | InvokeDynamic | u2 bootstrap method attr index, u2 NameAndType |
Tag 18 is worth noting: since Java 9, + on strings compiles to an
invokedynamic against StringConcatFactory rather than to
StringBuilder calls. If you are reading bytecode looking for string
concatenation feeding a SQL sink, on Java 9+ you are looking for
invokedynamic makeConcatWithConstants, not
StringBuilder.append.
04Decoding 48 real bytes
This is a genuine class file — a two-method class compiled by JDK 21.0.10, 973 bytes total.
$ xxd -l 48 Pipeline.class 00000000: cafe babe 0000 0041 003b 0a00 0200 0307 .......A.;...... 00000010: 0004 0c00 0500 0601 0010 6a61 7661 2f6c ..........java/l 00000020: 616e 672f 4f62 6a65 6374 0100 063c 696e ang/Object...<in
Reading forward from offset 0x0A, where the constant pool begins:
| Offset | Bytes | Decode |
|---|---|---|
| 0x0A | 0a | tag 10 — Methodref. This is entry #1 |
| 0x0B | 00 02 | class_index → #2 |
| 0x0D | 00 03 | name_and_type_index → #3 |
| 0x0F | 07 | tag 7 — Class. Entry #2 |
| 0x10 | 00 04 | name_index → #4 |
| 0x12 | 0c | tag 12 — NameAndType. Entry #3 |
| 0x13 | 00 05 | name_index → #5 |
| 0x15 | 00 06 | descriptor_index → #6 |
| 0x17 | 01 | tag 1 — Utf8. Entry #4 |
| 0x18 | 00 10 | length = 16 |
| 0x1A | 6a 61 76 61 … | 16 bytes: java/lang/Object |
| 0x2A | 01 | tag 1 — Utf8. Entry #5 |
| 0x2B | 00 06 | length = 6 |
| 0x2D | 3c 69 6e … | 6 bytes: <init> |
Checked against the compiler's own view:
$ javap -verbose Pipeline.class | sed -n '/Constant pool/,+6p' Constant pool: #1 = Methodref #2.#3 // java/lang/Object."<init>":()V #2 = Class #4 // java/lang/Object #3 = NameAndType #5:#6 // "<init>":()V #4 = Utf8 java/lang/Object #5 = Utf8 <init> #6 = Utf8 ()V
Entries #1 through #6 together encode one thing: a call to
Object's no-argument constructor. Resolving #1 means following
#2 → #4 for the class name and #3 → #5, #6 for the method name and
descriptor.
The source file declares no constructor and does not write extends
Object. Both appear in the compiled output because javac inserted
them. The source you read is not the program that runs. The compiler
adds members; frameworks add proxies and synthetic classes at runtime. That gap is
where findings hide, and it is the reason a review that only reads
.java files is incomplete.
05The long and double wart
A CONSTANT_Long or CONSTANT_Double entry occupies
two consecutive constant pool slots. The second slot is unusable
and must be skipped. The specification itself calls this a historical mistake.
// Correct constant pool iteration int i = 1; while (i < constantPoolCount) { int tag = in.readUnsignedByte(); readEntry(tag, in); if (tag == 5 || tag == 6) { // Long or Double i += 2; // consumes two slots } else { i += 1; } }
Get this wrong and the parser desynchronises at the first long
literal in the file, then reports garbage for everything after it. If you write the
WAR analysis tool in the final phase of the curriculum, this is the bug you will
hit first and it will look like file corruption rather than a loop error.
06Using this in triage
Find every class in a WAR built for an old target
#!/usr/bin/env bash # Report the target version of every class inside a WAR. # Read-only: never executes anything from the archive. war="$1" unzip -Z1 "$war" '*.class' | while read -r entry; do major=$(unzip -p "$war" "$entry" \ | dd bs=1 skip=6 count=2 2>/dev/null | xxd -p) printf '%-5s java-%-3s %s\n' "0x$major" $(( 0x$major - 44 )) "$entry" done | sort -u
Enumerate referenced types without running anything
$ javap -p -c -constants com/example/Foo.class | grep -oE '// \w+ [a-z/]+\.[A-Za-z]+' | sort -u # Dangerous-API sweep across an extracted WAR: $ find WEB-INF/classes -name '*.class' -exec javap -p -c {} + \ | grep -nE 'Runtime|ProcessBuilder|ObjectInputStream|InitialContext|createStatement'
A reference in the constant pool proves the class mentions a type. It does not prove the call is reachable, that it takes untrusted input, or that it executes at all — dead code and defensive branches reference the same APIs. Report it as a lead for manual review, never as a finding. Claiming otherwise is how a tool loses the reader's trust on its second report.
07Security consequences
Bytecode verification is a real boundary
Between loading and linking, the JVM verifies the bytecode: operand stack depths
must reconcile on every path, local variable types must be consistent, branch
targets must stay inside the method, and a reference may never be treated as a
primitive. This is what stops a malformed or hostile class file from corrupting JVM
memory. It is also why a corrupted file produces a VerifyError rather
than undefined behaviour.
// Corrupt the magic number of a copy and the loader refuses it outright: $ printf '\xde\xad\xbe\xef' | dd of=/tmp/broken/Pipeline.class bs=1 seek=0 conv=notrunc $ java -cp /tmp/broken Pipeline Error: ... Incompatible magic value 3735928559 in class file Pipeline
The version error you will meet in production
$ /opt/homebrew/opt/openjdk/bin/javac --release 24 -d /tmp/v24 Pipeline.java $ java -cp /tmp/v24 Pipeline UnsupportedClassVersionError: Pipeline has been compiled by a more recent version of the Java Runtime (class file version 68.0), this version of the Java Runtime only recognizes class file versions up to 65.0
Both numbers are in the message. 68 means the build targeted Java 24; 65 means the runtime is Java 21. The fix is to align them, and the information needed to decide which to move is the eight bytes at the top of this page.
What the format does not protect
- It is not a signature. Verification checks structural and type safety, not provenance. Any class that verifies will load, whatever produced it. JAR signing is a separate, optional mechanism.
- It is not obfuscation. Method names, field names, signatures
and string literals are all present in plaintext. Hardcoded credentials in a
compiled class are as readable as in source —
stringsfinds them. - Verification does not constrain behaviour. A perfectly valid
class file can call
Runtime.exec. The verifier checks that the bytecode is well-formed, never that what it does is acceptable.
The historical answer to that last point was SecurityManager with a
policy file. It was deprecated for removal by JEP 411 in Java 17 and
System.setSecurityManager now throws by default; JEP 486 completed the
removal in Java 24. If you review an application that depends on a security
policy to sandbox untrusted code, that control does not exist on a modern JVM.
Isolation now has to come from the process or container boundary instead.
Where does
major_versionlive, and how would you read it from a class inside a WAR without extracting the archive?Two bytes at offset 6, big-endian.unzip -p app.war path/To.class | dd bs=1 skip=6 count=2 | xxd -p. Subtract 44 for the Java release — 0x41 is 65, which is Java 21.javapreportsmethods: 3for a class whose source declares two. Who created the third, and what does that tell you about reviewing Java?javacgenerated the default constructor<init>, and it also insertedextends java.lang.Object. The source you read is not the program that runs: the compiler adds members and frameworks add runtime proxies, so a review that only reads.javafiles is incomplete.Why does a hand-written constant pool parser desynchronise at the first
longliteral in a file?CONSTANT_LongandCONSTANT_Doubleoccupy two consecutive pool slots; the second is unusable and must be skipped. Advance the index by 2 for tags 5 and 6. Get it wrong and every entry afterwards decodes as garbage, which reads like file corruption rather than a loop bug.Bytecode verification passed. Does that tell you the class is safe to run?
No. Verification checks structural and type safety — stack depths reconcile, branches stay in-method, a reference is never used as a primitive. It says nothing about provenance (it is not a signature) and nothing about behaviour: a perfectly valid class file can callRuntime.exec.