luap.in/java
← Index · 01 · JVM Verified on JDK 21.0.10

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.

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.

TagTypePayload
1Utf8u2 length, then modified-UTF8 bytes. Every actual string lives here
3Integeru4
4Floatu4
5Longu8 — takes two pool slots
6Doubleu8 — takes two pool slots
7Classu2 index to a Utf8 holding the internal name
8Stringu2 index to a Utf8
9Fieldrefu2 Class index, u2 NameAndType index
10Methodrefu2 Class index, u2 NameAndType index
11InterfaceMethodrefas Methodref
12NameAndTypeu2 name index, u2 descriptor index
15MethodHandleu1 reference kind, u2 reference index
18InvokeDynamicu2 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:

OffsetBytesDecode
0x0A0atag 10 — Methodref. This is entry #1
0x0B00 02class_index → #2
0x0D00 03name_and_type_index → #3
0x0F07tag 7 — Class. Entry #2
0x1000 04name_index → #4
0x120ctag 12 — NameAndType. Entry #3
0x1300 05name_index → #5
0x1500 06descriptor_index → #6
0x1701tag 1 — Utf8. Entry #4
0x1800 10length = 16
0x1A6a 61 76 61 …16 bytes: java/lang/Object
0x2A01tag 1 — Utf8. Entry #5
0x2B00 06length = 6
0x2D3c 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.

What this proves

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'
State the limitation

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 — strings finds 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 Security Manager is gone

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.

Check yourself
  1. Where does major_version live, 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.
  2. javap reports methods: 3 for a class whose source declares two. Who created the third, and what does that tell you about reviewing Java?
    javac generated the default constructor <init>, and it also inserted extends 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 .java files is incomplete.
  3. Why does a hand-written constant pool parser desynchronise at the first long literal in a file?
    CONSTANT_Long and CONSTANT_Double occupy 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.
  4. 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 call Runtime.exec.
luap.in/java · source Java 21 · Tomcat 10.1.61 · Struts 6 · Spring Boot 3