Skip to main content

javainspect

Standalone JAR and Java bytecode inspection, extracted from XantheProtections. Drop it into any application that needs to read .jar archives or .class files without pulling in a scanner, detection rules, or ML.

It parses, normalizes, searches — and edits. Reading does not classify (no checks, no scoring, no verdicts); editing can add or remove bytecode and repackage a jar. Build your own logic on top.

Only dependency: javatools.

Install

pip install ./libs/javainspect          # from this repo
pip install -e ./libs/javainspect       # editable, for development

Or vendor it: copy src/javainspect/ into your project and pip install javatools.

Quick start

from javainspect import JarInspector

with JarInspector("plugin.jar") as jar:
    print(jar.manifest.get("Main-Class"))
    print(len(jar.class_entries()), "classes")

    for class_file in jar.iter_classes():
        for instruction in class_file.get_method_calls():
            info = instruction.get_method_info()
            if info and info[0] == "java/lang/Runtime":
                print(class_file.name, "->", info)

    print(jar.errors)  # classes that failed to parse

A single class file, on disk or in memory:

from javainspect import parse_class, parse_class_bytes

class_file = parse_class("Main.class")
class_file = parse_class_bytes(open("Main.class", "rb").read())

What's in it

Archive layer — javainspect.jar

API Purpose
JarInspector(path, limits=, deobfuscate=) Context-managed handle over a jar
.entries() / .class_entries() / .resource_entries() Entry listing with sizes and CRCs
.manifest META-INF/MANIFEST.MF main attributes as a dict
.read(name) / .read_text(name) Size-capped raw reads
.iter_classes() / .classes() Parsed ClassFile objects
.parse_class(name) One class by archive name
.map_classes(fn, workers=) Parse + analyze across a process pool
.all_strings() / .class_names() Whole-archive aggregates
.errors ClassError list from the last traversal
.zipfile The underlying ZipFile, for zip-level work
iter_jar_classes(path) One-liner over a whole jar
parse_manifest(text) Manifest parsing on its own

JarLimits(max_entry_size, max_class_size) bounds what gets read, so a hostile archive can't zip-bomb the host process. Class entries named pkg/Real.class/ (the trailing-slash form used to dodge naive suffix filters) are recognized. Malformed classes are collected in .errors instead of aborting the walk.

Class model — javainspect.parser, javainspect.instruction

ClassFile → Method → Instruction, all frozen dataclasses.

  • ClassFile.get_method_calls(), .get_field_accesses() — cached, class-wide
  • ClassFile.get_all_strings() — literals plus low-noise decodes
  • ClassFile.get_deobfuscated_strings() — literals plus every plausible decode (noisy, opt-in)
  • Method.instructions, .exception_handlers, .descriptor
  • Instruction.get_method_info() → (owner, name, descriptor); also .get_field_info(), .get_string_constant(), .get_long_constant(), .opcode_name, and is_* predicates per opcode family

Matchers — javainspect.utils

Predicates over instructions that take a plain string (substring match) or a compiled regex:

from javainspect import is_method_call, method_matches_all
import re

is_method_call(instr, "java/lang/Runtime", "exec", None)
is_method_call(instr, None, re.compile(r"^(download|fetch)\w*File$"), None)

method_matches_all(method, [               # all must co-occur in ONE method
    lambda i: is_method_call(i, "java/net/URL", "openStream", None),
    lambda i: is_method_call(i, "java/io/FileOutputStream", "<init>", None),
])

Plus navigation (find_forward, find_backward, is_followed_by, previous_instruction, next_instruction), value recovery (get_array_store_values, get_string_array_literal), descriptor parsing (get_return_type, get_argument_types), and reflection helpers (uses_reflection_api, reflective_dangerous_targets).

String recovery — javainspect.utils, javainspect.deobfuscator

  • deobfuscate_string_candidates(s) — high-confidence base64/hex decodes plus aggressive decodes that look meaningful. Safe to consume anywhere.
  • deobfuscate_string_candidates_deep(s) — every plausible decode. Noisy, opt-in.
  • optimize_class(class_file) — experimental in-place folding of constant arithmetic, String.concat chains, and StringBuilder append-spam. Also reachable as JarInspector(..., deobfuscate=True).

Library recognition — javainspect.allowlist

known_library_match(class_file) returns the name of a recognized bundled library (bStats, ProtocolLib) or None. KNOWN_LIBRARY_FINGERPRINTS and KNOWN_LIBRARY_PACKAGE_PREFIXES are plain dicts — extend them for whatever your own application bundles.

Type hierarchy and creation sites

Two cross-class questions, answered over a whole jar — what does a method derive from and where is a class created:

from javainspect import JarInspector, find_creators

with JarInspector("plugin.jar") as jar:
    h = jar.type_hierarchy()

    h.superclass("com.x.Dog")            # -> "com.x.Animal"
    h.interfaces("com.x.Dog")            # -> ("com.x.Named",)
    h.ancestors("com.x.Dog")             # transitive supers, nearest first
    h.subclasses("com.x.Animal")         # direct subclasses
    h.implementors("com.x.Named")        # classes implementing an interface
    h.subtypes("com.x.Animal")           # everything transitively below
    h.is_subtype("com.x.Dog", "com.x.Animal")   # True

    # What a method overrides / implements (which supertype declares it):
    h.method_origins("com.x.Dog", "sound", "()Ljava/lang/String;")  # -> ["com.x.Animal"]

    # Where is a type instantiated?  (every `new T` site -> class + method)
    for site in jar.instantiations().get("com.x.Dog", []):
        print(site.in_class, site.in_method, site.in_descriptor)

method_origins reports only supertypes present in the jar, nearest first — an override of a JDK/library method (or a method the class introduces) comes back empty. Constructors and static initializers are never inherited, so they're always empty. Names are dotted, matching ClassFile.name; find_creators accepts either dotted or internal (com/x/Dog) form. On the CLI:

javainspect hierarchy plugin.jar com.x.Dog     # extends/implements/ancestors/subtypes
javainspect hierarchy plugin.jar               # list every class's supertypes
javainspect creators  plugin.jar com.x.Dog     # every `new com.x.Dog` site

Individual instructions expose the raw signal too: Instruction.is_new() / get_new_type() for allocations, and ClassFile.super_name / interface_names for one class's own links.

Resolving a method reference, and finding callers

If all you have is a symbolic call like HookManager.hook() (which is exactly what an invoke instruction carries), resolve it to the class that actually declares it — walking the hierarchy the way the JVM does when the method is inherited:

from javainspect import resolve_method, find_callers

# Child inherits hook() from Base -> resolves up to Base:
resolve_method(classes, "Child", "hook")          # -> MethodRef(owner="Base", name="hook", descriptor="()V")
resolve_method(classes, "Child", "hook", "()V")   # pass a descriptor to disambiguate overloads

# Who calls it? (matches the owner as written at each call site)
for site in find_callers(classes, "Base", "hook"):
    print(site.in_class, site.in_method, "->", site.target, f"({site.opcode})")

calls(classes)   # every call site in the set, as CallSite records

resolve_method returns None when no class in the set declares it — i.e. it's inherited from a JDK/library type you don't have. find_callers matches the symbolic owner named at the call site (a call to an inherited method names the subclass, not the declarer), so combine the two to go from "who calls Child.hook" to "where hook really lives". On the CLI:

javainspect resolve plugin.jar Child hook          # -> declared by Base
javainspect callers plugin.jar Base hook           # every call site
javainspect callers plugin.jar com.x.HookManager   # all calls to any method on it

Editing bytecode and rewriting jars

The read side uses javatools, which can't write class files, so the edit side (javainspect.classfile, javainspect.transform) is a self-contained class-file writer. It is deliberately conservative so the output stays verifiable by the JVM:

  • Append-only constant pool — new constants are appended, existing indices never move, so every field, every other method, and every class attribute is copied through byte-for-byte.
  • Surgical Code rewrite — only the edited method's bytecode, max_stack, exception table, LineNumberTable, LocalVariableTable, and — the part that makes or breaks verification — StackMapTable are rewritten, with all bytecode offsets shifted consistently.

A no-op read/write cycle reproduces the input byte-for-byte, and the test suite loads an edited class through a JVM class loader (which forces the verifier to check the rewritten StackMapTable).

Inject / remove a log line

The headline transform mirrors the common ASM "add a log line to onEnable" recipe — it prepends Bukkit.getLogger().log(Level.INFO, message) to onEnable()V / onPluginEnable()V:

from javainspect import transform_jar, inject_log_code, remove_log_code

# Inject into every class in a jar (in place or to a new path):
edits = transform_jar("plugin.jar", "plugin-tagged.jar",
                       lambda ed: inject_log_code(ed, "Loaded by javainspect"))
for e in edits:
    if e.changed:
        print("injected into", e.name, e.methods)

# Remove it again — the exact inverse; only strips a prefix that resolves to
# the injected getLogger/log shape, so ordinary code is never touched:
transform_jar("plugin-tagged.jar", "plugin-clean.jar", remove_log_code)

Editing one class file directly:

from javainspect import ClassFileEditor, inject_log_code

editor = ClassFileEditor(open("Main.class", "rb").read())
if inject_log_code(editor, "hello"):          # returns edited method names
    open("Main.class", "wb").write(editor.serialize())

Write your own bytecode — the assembler

You don't have to hand-pack opcodes. javainspect.asm lets you write instructions the way javap shows them, and works out the constant-pool entries and max_stack for you. Your System.out.println example, verbatim:

from javainspect import ClassFileEditor, prepend_assembly

editor = ClassFileEditor(open("Main.class", "rb").read())
prepend_assembly(editor, '''
    getstatic java/lang/System.out Ljava/io/PrintStream;
    ldc "[Xanthe Protections] This Plugin is stupid"
    invokevirtual java/io/PrintStream.println (Ljava/lang/String;)V
''')
open("Main.class", "wb").write(editor.serialize())

Text syntax: one instruction per line; member operands are owner/pkg/Class.member with the descriptor as a separate token; ldc takes a "quoted string" or an integer; ; or // starts a comment (the trailing ; of a descriptor is safe). Same thing with the fluent CodeBuilder if you'd rather stay in Python:

from javainspect import ClassFileEditor, prepend_with

editor = ClassFileEditor(open("Main.class", "rb").read())
prepend_with(editor, lambda b: (
    b.getstatic("java/lang/System", "out", "Ljava/io/PrintStream;"),
    b.ldc("[Xanthe Protections] This Plugin is stupid"),
    b.invokevirtual("java/io/PrintStream", "println", "(Ljava/lang/String;)V"),
))

Across a whole jar, and its ready-made shorthand + inverse:

from javainspect import transform_jar, inject_println, remove_println, prepend_assembly

transform_jar("plugin.jar", "out.jar", lambda ed: inject_println(ed, "[Xanthe] loaded"))
transform_jar("plugin.jar", "out.jar",
              lambda ed: prepend_assembly(ed, open("myinjection.txt").read()))
transform_jar("out.jar", "clean.jar", remove_println)   # exact inverse

inject_println uses System.out, so unlike inject_log_code (which needs a Bukkit server for the Bukkit.getLogger() call to resolve) the line prints in a plain JVM — handy for testing. See examples/inject_custom_example.py for a CLI around all of this (--println, --asm FILE, --builder, --remove-println).

The one rule: an injected sequence must be stack-neutral — it has to leave the operand stack exactly as it found it, because the method's original code runs right after. The assembler simulates the stack from empty and raises if a sequence would underflow; it has no labels or branches (injected control flow would need stack-map frame synthesis the writer doesn't do).

Supported instructions: the common straight-line set — getstatic/putstatic/ getfield/putfield, invokevirtual/invokespecial/invokestatic/ invokeinterface, new/anewarray/checkcast/instanceof, ldc(+ldc_class), bipush/sipush, aload/iload/astore/istore(+_0.._3), dup/pop/ swap/… and the returns. Emitting anything unmodelled raises rather than guessing a wrong max_stack.

Lowest level

If you want to skip the assembler, append constants on the pool (add_methodref, add_fieldref, add_string, add_integer, add_class, …), pack the bytes yourself, and hand them to CodeAttribute.prepend(bytes, min_max_stack) (or .remove_prefix(n)):

import struct
from javainspect import ClassFileEditor

editor = ClassFileEditor(open("Main.class", "rb").read())
pool = editor.pool
out = pool.add_fieldref("java/lang/System", "out", "Ljava/io/PrintStream;")
msg = pool.add_string("hi")
println = pool.add_methodref("java/io/PrintStream", "println", "(Ljava/lang/String;)V")

code_bytes = (struct.pack(">BH", 0xB2, out)      # getstatic System.out
              + struct.pack(">BH", 0x13, msg)    # ldc_w "hi"
              + struct.pack(">BH", 0xB6, println))  # invokevirtual println

method = editor.find_method("onEnable", "()V")
code = method.get_code(pool)
code.prepend(code_bytes, min_max_stack=2)   # you supply max_stack here
method.set_code(pool, code)
open("Main.class", "wb").write(editor.serialize())

Scope and limits of the writer:

  • Edits insert or remove at the start of a method. Inserting straight-line, stack-neutral code there (like a log/telemetry call) needs no new stack-map frames and is the well-trodden path. Inserting code with its own branches, or editing mid-method, would need frame synthesis this does not do.
  • remove_prefix refuses if any metadata offset falls inside the removed region (i.e. the removed code was branched to or referenced) — a guard against corrupting a method that isn't a clean injected prefix.
  • Removal restores the bytecode exactly but leaves the appended constants in the pool (unused constants are harmless), so a round-tripped file is not byte-identical to the pre-injection original.
  • Code sub-attributes that could carry their own bytecode offsets but aren't parsed (e.g. RuntimeVisibleTypeAnnotations on code — rare in plugins) are copied unshifted and surfaced in ClassEdit.warnings rather than silently corrupted.

Inject anywhere — before every return

prepend_* inserts at method start. To insert before every return instead (an onDisable cleanup line, telemetry on exit), use the before-return variants. The rewriter fixes up branch offsets, tableswitch/lookupswitch padding, and stack-map frames so the result verifies even in a method full of control flow:

from javainspect import transform_jar, inject_println_before_return, prepend_assembly_before_return

transform_jar("plugin.jar", "out.jar",
              lambda ed: inject_println_before_return(ed, "[Xanthe] disabling"))
transform_jar("plugin.jar", "out.jar",
              lambda ed: prepend_assembly_before_return(ed, open("bye.txt").read()))

Add whole new methods and fields

Synthesize a helper method (and fields), then call it from injected code:

from javainspect import ClassFileEditor, add_static_method, prepend_assembly
from javainspect import ACC_PUBLIC, ACC_STATIC

editor = ClassFileEditor(open("Main.class", "rb").read())

add_static_method(editor, "xanthe$hello", "()V", '''
    getstatic java/lang/System.out Ljava/io/PrintStream;
    ldc "hello from an injected method"
    invokevirtual java/io/PrintStream.println (Ljava/lang/String;)V
    return
''')
editor.add_field(ACC_PUBLIC | ACC_STATIC, "xantheFlag", "Z")

# call the new method from onEnable
prepend_assembly(editor, f"invokestatic {editor.this_class_name}.xanthe$hello ()V")
open("Main.class", "wb").write(editor.serialize())

add_static_method assembles a full method body (which must end in the return matching the descriptor) and computes max_stack/max_locals. Use editor.this_class_name to build the invokestatic back to it.

Remove and rename classes and members

Remove a method or field from a class (class-local — like deleting it in source; call sites become your responsibility):

from javainspect import ClassFileEditor

editor = ClassFileEditor(open("Main.class", "rb").read())
editor.remove_method("removeMe", "()V")
editor.remove_field("unused", "I")
open("Main.class", "wb").write(editor.serialize())

Delete whole classes from a jar:

from javainspect import remove_classes
remove_classes("plugin.jar", "out.jar", ["com/x/Debug", "com/x/OldThing"])

Rename classes across a whole jar, reference-correctly — every subclass, field/method descriptor, and new that mentions the type is updated, and each renamed .class entry is moved to its new path:

from javainspect import rename_classes_in_jar

report = rename_classes_in_jar("plugin.jar", "out.jar", {
    "com/old/Foo": "com/new/Bar",
    "me/author/Secret": "me/author/a",   # e.g. obfuscate/deobfuscate names
})
print(report.edited_classes, "classes updated")
print(report.renamed_entries)            # [(old_path, new_path), ...]
print(report.string_literals)            # class names found in string constants

It works by rewriting only the type-bearing Utf8 constants, so pool indices (and therefore all bytecode) stay untouched — the output verifies and runs. Two things it deliberately does not touch, and reports instead in report.string_literals so you can handle them: class names that appear only as runtime strings (Class.forName("com.old.Foo")) and generic Signature attributes. It renames types, not members — patch plugin.yml/the manifest separately if they name a renamed class.

Edit the manifest and resources

For everything in the archive that isn't a class — javainspect.jaredit:

from javainspect import patch_manifest, set_resources, remove_entries, rewrite_jar

patch_manifest("plugin.jar", "out.jar", {"Main-Class": "com.x.Loader",
                                         "Sealed": "true"})   # None value removes a key
set_resources("plugin.jar", "out.jar", {"plugin.yml": b"name: X\nmain: com.x.Loader\n"})
remove_entries("plugin.jar", "out.jar", ["unwanted.txt"])

# arbitrary per-entry edit:
rewrite_jar("plugin.jar", "out.jar",
            edit_entry=lambda name, data: data.replace(b"debug=false", b"debug=true")
                       if name == "config.txt" else None)

Manifest values are wrapped to the 72-byte line limit automatically, and per-entry manifest sections are preserved.

Validate the result

Editing bytecode is exacting, so check the output. validate_* re-decodes every method and confirms all branch/switch/exception/stack-map offsets land on real instruction boundaries — the corruption a bad edit produces. It's deterministic, needs no JVM, and never runs the plugin's code:

from javainspect import validate_jar, transform_jar, inject_println

problems = validate_jar("out.jar")          # {} means clean
if problems:
    for name, issues in problems.items():
        print(name, issues)

# or validate edited classes inline as you write them:
transform_jar("plugin.jar", "out.jar", lambda ed: inject_println(ed, "x"), validate=True)

This is a fast structural gate, not a full type-checker — it won't catch a too-small max_stack (neither, reliably, does HotSpot's lazy verifier). To be certain a class type-checks, actually run it; the test suite does exactly that.

Command line

Installing the package puts a javainspect command on your path (or run python -m javainspect.cli):

javainspect inspect plugin.jar                     # entries, manifest, entry methods
javainspect strings plugin.jar --deobf --min 6     # dump string constants
javainspect manifest plugin.jar --set Main-Class=com.x.Loader -o out.jar
javainspect inject-println plugin.jar "[Xanthe] loaded" -o out.jar
javainspect inject-println plugin.jar "bye" --before-return -o out.jar
javainspect asm plugin.jar myinjection.txt -o out.jar
javainspect remove-println out.jar -o clean.jar
javainspect validate out.jar

Parallel analysis

map_classes spreads parsing across cores. fn and its return value must be picklable, so use a module-level function:

from javainspect import JarInspector, is_method_call

def find_exec(class_file, name):
    return [
        i.get_method_info()
        for i in class_file.get_method_calls()
        if is_method_call(i, "java/lang/Runtime", "exec", None)
    ]

with JarInspector("plugin.jar") as jar:
    for entry_name, hits in jar.map_classes(find_exec):
        if hits:
            print(entry_name, hits)

Notes

  • Instruction.get_method_info() returns None for invokedynamic, which has no owner class — its constant-pool entry resolves to a bootstrap-method index. Check opcode == OP_invokedynamic directly if you need those.
  • Instruction offsets are original class-file byte offsets, not list positions. optimize_class preserves offsets on retained instructions and leaves the exception table alone.
  • Methods without code (abstract/native) are kept with an empty instruction list so name-based analysis still sees them.

Metadata

Release files for javainspect 1.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for javainspect 1.1.0
File Size Uploaded
javainspect-1.1.0.tar.gz 89.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for javainspect 1.1.0
File Interpreter ABI Platform
javainspect-1.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 164.8 kB

Release files / javainspect-1.1.0.tar.gz

Download URL javainspect-1.1.0.tar.gz
Size 89.3 kB
Tags Source
SHA-256 checksum
How to use checksums
51b96d6687bb9dbc1aded39ff9d5b189507855f7db3a417ea321e4eb80458806
BLAKE2b-256 checksum
How to use checksums
54ef43b78b6c12bebf984160a8d4b63688f3a0945a41893972bade6fe89094be
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.2

Release files / javainspect-1.1.0-py3-none-any.whl

Download URL javainspect-1.1.0-py3-none-any.whl
Size 75.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
38cab4cc828c72003f00db1faf489794a22cc823cf08961423038e9b3fcacb43
BLAKE2b-256 checksum
How to use checksums
834d2978bba7aa9b53129c1e36aa193e3ee0ceb249e9f34d8b9629064b229564
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.2

Release history Release notifications | RSS feed

This release

1.1.0 This release

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page