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.

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.

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.0.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.0.0
File Size Uploaded
javainspect-1.0.0.tar.gz 73.4 kB Details

Built distribution (wheel)

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

Total release size: 136.8 kB

Release files / javainspect-1.0.0.tar.gz

Download URL javainspect-1.0.0.tar.gz
Size 73.4 kB
Tags Source
SHA-256 checksum
How to use checksums
c5ff2b3225367dad84da0209d2933a2fdacc7f219d7340490fcacbe320647d93
BLAKE2b-256 checksum
How to use checksums
60aa06a104ece96207abf2334b3ab2d874804d591b8cc32d83b688205aa279e1
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.0.0-py3-none-any.whl

Download URL javainspect-1.0.0-py3-none-any.whl
Size 63.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
30bebeee3f06cce8366834df1652949eb7760f67091f5f8abba2509759ab2047
BLAKE2b-256 checksum
How to use checksums
79a7094034ddbdd5e57df6e6d2987dfa0c791c1576643b448349f0d54429b7f4
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

1.1.0

2 release files

This release

1.0.0 This release

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