- Python 100%
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| examples | ||
| scanner | ||
| tests | ||
| .gitignore | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
plugin-leak-scanner
Static analysis for Netty ByteBuf and JDK DirectByteBuffer leak patterns
in Minecraft plugin jars. Built after the 2026-05-05 incident where
ExtendedHorizons-3.1.2-dev2 exhausted the Netty direct-buffer pool because
a Caffeine.Cache<ChunkKey, CompletableFuture<ByteBuf>> was created without
a removalListener — expired entries dropped their ByteBuf on the floor
without release(), players got kicked, and the server couldn't accept new
connections until restart.
The scanner is intended to be run as a CI gate before any plugin upgrade hits production.
What it scans
Pattern A — Caffeine cache of ByteBuf without removal listener (HIGH)
The exact shape of the EH bug. For every method we walk the bytecode and
isolate "builder chains": from Caffeine.newBuilder() to the next
Caffeine.build*(). Inside each chain we record whether removalListener
or evictionListener was called. We then correlate each chain's astore
slot with the class field it eventually gets putfield'd into, and look up
that field's generic Signature: attribute. If the value type involves
io.netty.buffer.ByteBuf (raw, Optional<ByteBuf>, CompletableFuture<ByteBuf>,
etc.) and no listener was attached, we emit a HIGH finding.
Caffeine's package is matched by suffix (/caffeine/cache/Caffeine) so the
scanner works against shaded relocations like
me/mapacheee/lib/caffeine/cache/Caffeine.
Pattern B — ByteBuf alloc/release imbalance (MEDIUM)
Class-level heuristic. Counts calls to:
- Allocators:
(Pooled|Unpooled)?ByteBufAllocator.{directBuffer,heapBuffer,buffer,ioBuffer,...},Unpooled.{directBuffer,...},ByteBufUtil.{wrappedBuffer,...} - Releases:
ReferenceCountUtil.{release,safeRelease},ByteBuf.release
If alloc - release > 5 on a single class, we emit a MEDIUM finding. This
is intentionally conservative; it's a "look here" pointer, not proof of a
bug.
Pattern C / D — skipped for v1
ByteBuffer.allocateDirect() without a reachable Cleaner, and
ChannelInboundHandler retaining ByteBuf in fields without an explicit
release in channelInactive/handlerRemoved, are both hard to verify
without a real data-flow analysis. Listed in scanner/patterns/__init__.py
TODO for a future pass.
Usage
plugin-leak-scanner SCAN /path/to/MyPlugin.jar # JSON to stdout
plugin-leak-scanner SCAN /path/to/MyPlugin.jar --text # human-readable
plugin-leak-scanner SCAN /path/to/MyPlugin.jar --exit-code-on-high
plugin-leak-scanner BATCH /data/plugins # all *.jar
--exit-code-on-high exits with code 2 when any HIGH finding is present;
that's what you wire into CI:
# .forgejo/workflows/plugin-gate.yml
- name: scan plugin
run: python -m scanner SCAN dist/MyPlugin.jar --exit-code-on-high
The JSON report shape:
{
"jar": "ExtendedHorizons-3.1.2-dev2.jar",
"scanned_at": "2026-05-05T20:00:00Z",
"classes_scanned": 145,
"classes_failed": 0,
"findings": [
{
"severity": "HIGH",
"pattern": "A",
"class": "me/mapacheee/extendedhorizons/fakechunks/cache/ChunkBuildCacheService",
"method": "rebuildCaches",
"field": "buildEntryCache",
"build_offset": 87,
"msg": "Caffeine cache of ByteBuf (field 'buildEntryCache') built without removalListener/evictionListener — see 2026-05-05 ExtendedHorizons incident"
}
],
"summary": {"high": 1, "medium": 0, "low": 0}
}
Requirements
- Python 3.10+ (stdlib only — no pip packages)
- JDK with
javapon disk. Override with theJAVAP_BINenv var if it's not at one of the default candidates (/usr/lib/jvm/java-21-openjdk-amd64/bin/javap,/usr/lib/jvm/default-java/bin/javap, or anything resolved byPATH).
Known false positives
- Caffeine builders that hand the cache to a third party for listener installation. If the builder is returned from the method and the listener is attached by the caller, we'll flag it. Rare in practice.
- Builder chains that store into an unindexed local slot we can't track.
When
Caffeine.build()is followed byastoreinto a slot we couldn't parse from javap output (very rare), and the class has at least oneCache<*, ByteBuf*>field, we drop the severity to MEDIUM with a note rather than assert HIGH. - Pattern B noise. Encoders that allocate buffers for downstream
ChannelHandlerContext.write()always look like they "leak" because the release happens in Netty's pipeline. Investigate before acting.
Tests
PLUGIN_LEAK_SCANNER_LEAKY_JAR=/tmp/extendedhorizons-leaky-real.jar \
PLUGIN_LEAK_SCANNER_FIXED_JAR=/tmp/extendedhorizons-fixed.jar \
python -m unittest tests.test_known_cases
Both jars are required for the regression suite to assert the scanner still catches the 2026-05-05 case.
Limitations
- javap-based, no full data-flow. We track
astore N → aload N → putfieldone hop deep; anything richer (chains across method boundaries, conditional builders) needs ASM. We accept that trade-off to keep the scanner zero-dependency. - Per-class disassembly is
O(jars × classes). Scanning AnarchyExploitFixes (1533 classes) on a typical workstation takes ~30–40 s.