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.
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.
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.0.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.0.0.tar.gz | 73.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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
|