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-wideClassFile.get_all_strings()— literals plus low-noise decodesClassFile.get_deobfuscated_strings()— literals plus every plausible decode (noisy, opt-in)Method.instructions,.exception_handlers,.descriptorInstruction.get_method_info()→(owner, name, descriptor); also.get_field_info(),.get_string_constant(),.get_long_constant(),.opcode_name, andis_*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.concatchains, andStringBuilderappend-spam. Also reachable asJarInspector(..., 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
Coderewrite — only the edited method's bytecode,max_stack, exception table,LineNumberTable,LocalVariableTable, and — the part that makes or breaks verification —StackMapTableare 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_prefixrefuses 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.
Codesub-attributes that could carry their own bytecode offsets but aren't parsed (e.g.RuntimeVisibleTypeAnnotationson code — rare in plugins) are copied unshifted and surfaced inClassEdit.warningsrather 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()returnsNoneforinvokedynamic, which has no owner class — its constant-pool entry resolves to a bootstrap-method index. Checkopcode == OP_invokedynamicdirectly if you need those.- Instruction offsets are original class-file byte offsets, not list positions.
optimize_classpreserves 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)
| File | Size | Uploaded | |
|---|---|---|---|
| javainspect-1.1.0.tar.gz | 89.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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
|