A 22-kilobyte truncated ZIP file crashes an entire JVM — but only on aarch64, only with the Maven artifact, and only when built by gcc 4.9.4. A journey from a
hs_errlog down to the exact source line, through cross-compilation, byte-level reproduction, and the ghost of a ten-year-old compiler.
The Scene
It begins, as these stories often do, with a SIGSEGV that kills the entire Java process. A service running on an aarch64 machine calls into SevenZipJBinding — the popular JNI binding around the 7-Zip/p7zip C++ library — to open a ZIP archive. For a well-formed archive, everything is fine. But feed it a truncated ZIP — one with local file headers but no End Of Central Directory (EOCD) record — and the JVM vanishes in a puff of native code:
# SIGSEGV (0xb) at pc=0x...2038, pid=..., tid=...
# Problematic frame:
# C [lib7-Zip-JBinding.so+0x102038]
# Java_net_sf_sevenzipjbinding_SevenZip_nativeOpenArchive+0x9b4
#
# Core dump written. Default location: /work/core or core.5856
Three things make this case genuinely hard:
- It only happens on aarch64. The x86_64 artifact is unaffected.
- It only happens with the Maven-published artifact. Rebuilding the library from source — even with the same gcc version — does not reproduce it.
-
The crash address is stable (
lib7-Zip-JBinding.so+0x102038), but the binary is stripped, soaddr2linereturns??:0.
What follows is the story of how we closed all three gaps — identifying the toolchain, byte-for-byte reproducing the crash, and finally resolving the faulting instruction to a single line of C++.
Step 1 — Reading the Wreckage
The HotSpot fatal error log (hs_err_pid*.log) gives us the crash register state and a fragment of disassembly:
siginfo: si_signo: 11 (SIGSEGV), si_code: 1 (SEGV_MAPERR), si_addr: 0x0
Registers:
R0=0x0053005797794eac R29=0x00000055039fdb30 ...
Instructions: (pc=0x...2038)
0x...2028: e2 03 15 aa a9 80 ff 97 f4 03 00 aa a0 01 66 9e
0x...2038: 16 04 40 f9 56 23 00 b4 15 08 40 f9 e0 03 15 aa
The crash is a null-deref (si_addr=0x0). Let's decode the faulting instruction. The bytes at 0x...2038 are 16 04 40 f9, which in little-endian is 0xf9400416 — an A64 LDR (immediate, unsigned offset):
ldr x22, [x0, #8] ; load 8 bytes from address (x0 + 8)
The instruction just before it (a0 01 66 9e = 0x9e6601a0) is:
fmov x0, d13 ; move FP register d13 into GP register x0
So the faulting instruction reads [d13 + 8], and d13 holds an invalid value. This is unusual: a floating-point register is being used to hold a pointer. The compiler (gcc 4.9) has decided to spill a pointer into a callee-saved FP register (d8–d15) to relieve register pressure on the GP file.
Step 2 — Disassembling the Maven Artifact
We pull the .so out of the jar and disassemble around the crash site with a cross-architecture objdump:
$ unzip -p sevenzipjbinding-linux-arm64-16.02-2.01.jar Linux-arm64/lib7-Zip-JBinding.so \
| objdump ... -d -C --start-address=0x101f00 --stop-address=0x102200
102014: bl jni::JMethod::initMethodID
102018: ldr x21, [x21, #24]
10201c: cbz x21, 10268c
102020: mov x0, x27 ; JNIEnv*
102024: mov x1, x22 ; jclass
102028: mov x2, x21 ; jmethodID
10202c: bl JNIEnv_::NewObject ; --- create Java object ---
102030: mov x20, x0 ; save return value
102034: fmov x0, d13 ; x0 = d13 (this pointer) ← ★
102038: ldr x22, [x0, #8] ; *** SEGV: read this->field@8 *** ← CRASH
10203c: cbz x22, 1024a4 ; NULL check
The crash is right after a NewObject call. The code loads a value from d13, then dereferences it. This looks like the textbook pattern of "forgot to check for a pending JNI exception" — and indeed, running with -Xcheck:jni had already warned us:
WARNING in native method: JNI call made without checking exceptions
when required to from CallStaticObjectMethodV
But that tempting narrative turns out to be wrong. Notice the crash dereferences d13, not the NewObject return value (which was saved into x20 and never touched). So this is not a "used a NULL JNI return value" story. We need to know what d13 actually is.
Step 3 — Tracing d13 Back to Its Birth
Disassembling the whole nativeOpenArchive function from its start (0x101684) and grepping for d13 reveals its entire life:
101694: stp d12, d13, [sp, #128] ; prologue: save callee-saved d13
...
1017b8: bl operator new(0x18) ; new JNIEnvInstance (24 bytes) ← ★
1017bc: add x2, x29, #0xd0
1017cc: fmov d13, x2 ; *** d13 = address of the new object ***
...
101df0: fmov x0, d13 ; used (still valid here)
101dfc: fmov x0, d13 ; used (still valid here)
102034: fmov x0, d13 ; *** used again, now corrupted *** ← CRASH
d13 is assigned exactly once, at 0x1017cc, immediately after a new(0x18). It holds the address of a freshly allocated object — specifically, a JNIEnvInstance, the wrapper the library uses to scope JNI calls within this function. After the assignment, d13 is only read, never written again.
Here's the contradiction. d13 is callee-saved (AAPCS64 reserves the low 64 bits of d8–d15 as callee-saved). By the ABI, every bl we call between 0x1017cc and 0x102034 — including initMethodID and NewObject — must preserve it. Yet by 0x102034 it is garbage. Something along that call chain corrupted a callee-saved register that held a this pointer.
Step 4 — The Toolchain Fingerprint
The artifact is stripped, so addr2line gives us nothing:
$ addr2line -e lib7-Zip-JBinding.so -f -C 0x102038
?? ??:0
We need to rebuild with debug info — but every rebuild we tried (gcc 11, gcc 15, even Linaro gcc 4.9.4) did not crash. The bug refuses to be reborn from source. So our first question became: what exactly built the Maven artifact?
The ELF .comment section answers. It's written by gcc and usually survives stripping:
$ readelf -p .comment lib7-Zip-JBinding.so
String dump of section '.comment':
[ 0] GCC: (crosstool-NG ) 4.9.4
gcc 4.9.4, via crosstool-NG. gcc 4.9 was the first stable gcc to support aarch64, and its backend was notoriously immature — aggressive about spilling pointers into FP callee-saved registers being exactly the kind of thing it did. Later gcc rewrote the aarch64 backend; that's why every modern rebuild dodges the crash.
Step 5 — Why "Same gcc" Still Doesn't Reproduce
We tried rebuilding with Linaro's prebuilt gcc 4.9.4 aarch64 toolchain. Same gcc version, same -O3. It did not crash. Same compiler, different result — how?
Because crosstool-NG and Linaro ship different binutils, glibc, and patch sets. Code generation for UB is exquisitely sensitive to the exact binary layout, and a slightly different instruction stream shifts the register allocator's decisions. The crash lives in the intersection of "gcc 4.9.4's aarch64 backend" and "the specific crosstool-NG configuration Maven used." Reproducing it required reproducing the toolchain, not just the compiler.
Step 6 — Finding the Exact Toolchain
Where did crosstool-NG 4.9.4 come from? The project's README pointed to DockCross — cross-compilation toolchains shipped as Docker images. Digging into DockCross's git history, we found the smoking gun:
commit 37c54a3 Thu Apr 16 2020
[linux-arm64] bump up the version of gcc to 8
linux-arm64 is currently using gcc 4...
And the config just before that commit:
CT_CC_GCC_VERSION="4.9.4"
CT_CC_GCC_V_4_9_4=y
SevenZipJBinding 16.02-2.01 was released in January 2020 — three months before DockCross bumped arm64 to gcc 8. So the release was built with dockcross/linux-arm64 at gcc 4.9.4. The .comment string is an exact match. We had the toolchain.
Step 7 — Byte-for-Byte Reproduction
Old Docker Hub tags get garbage-collected, but the tag dockcross/linux-arm64:20200119-1c10fb2 was still pullable. Building SevenZipJBinding with it produced a .so whose .comment matched, and then — finally — the crash came back:
| Maven artifact | Our dockcross rebuild | |
|---|---|---|
| Crashes on bad.zip? | Yes | Yes |
| Crash offset | +0x102038 |
+0x102038 |
| Faulting instruction bytes | 9e6601a0 f9400416 |
9e6601a0 f9400416 |
.comment |
crosstool-NG 4.9.4 |
crosstool-NG 4.9.4 |
Byte-identical at the crash site. We now had a crashing binary whose source we controlled.
Step 8 — The Source Line
One more rebuild, this time adding -g3 -ggdb to the Release flags (keeping -O3 so the codegen didn't shift). The crash offset stayed at 0x102038, and addr2line finally spoke:
$ addr2line -e lib7-Zip-JBinding-dockcross-g.so -f -C -i -p 0x102038
JNIEnvInstance::exceptionCheck()
at jbinding-cpp/JBindingTools.h:337
(inlined by) JavaToCPPSevenZip.cpp:291
The source confirms the disassembly story. In JavaToCPPSevenZip.cpp:
// line 290 - create the Java InArchiveImpl object
jobject inArchiveImplObject = jni::InArchiveImpl::_newInstance(env); // -> NewObject
// line 291 - check whether NewObject threw
if (jniEnvInstance.exceptionCheck()) { // *** CRASH ***
archive->Close();
...
}
And exceptionCheck() is inlined from JBindingTools.h:
bool exceptionCheck() {
if (_jniNativeCallContext) { // line 337 - reads this->_jniNativeCallContext
return _jniNativeCallContext->exceptionCheck(_env);
}
return _jbindingSession.exceptionCheck(_env);
}
The member access this->_jniNativeCallContext compiles to ldr x, [this, #8] — and this is d13. Everything lines up. The full causal chain:
- A truncated ZIP (no EOCD) is opened; the archive ends up in an inconsistent state.
-
_newInstance(env)callsNewObjectto create the JavaInArchiveImpl. - During that call, the
JNIEnvInstance::thispointer — held in callee-saved FP registerd13— gets corrupted (the physical-hardware crash givessi_addr=0x24, sod13became0x1c). - The inlined
exceptionCheck()readsthis->_jniNativeCallContextvialdr x22, [d13, #8], dereferences the corruptedthis, and faults.
gcc 4.9.4's decision to keep this in d13 across a JNI call is the load-bearing mistake. Every modern gcc uses a GP callee-saved register instead, which is why the bug only shows up in this one binary.
The lesson: "Build with the latest compiler" is not just about new features — for UB-laden legacy code, it can be the difference between a flawless release and a JVM that vanishes on one architecture only. A compiler bug from 2014, frozen into a published artifact, can lie dormant for years until someone hands it the wrong 22 KB of input.
The Fix
For end users, an immediate, 100% effective workaround is to validate the ZIP before handing it to SevenZipJBinding — reject anything without an EOCD record. For the project itself, the clean fix is to rebuild the aarch64 artifact with a modern gcc (≥5); we confirmed that gcc 8 (current DockCross), gcc 11, gcc 15, and even Linaro 4.9.4 all avoid the crash.
Appendix — The Tools That Got Us There
-
hs_err_pid*.log— HotSpot's register dump and the raw faulting bytes were the foundation. -
readelf -p .comment— the toolchain fingerprint that named gcc 4.9.4. - DockCross git history (
git log -S "4.9" -- linux-arm64) — pinned the exact image used at release time. -
dockcross/linux-arm64:20200119-1c10fb2— the time machine that reproduced the binary. -
addr2line -g3— turned bytes into a source line.
Reproduce It Yourself
All scripts used in this investigation — the dockcross reproducer, the addr2line harness, and an A/B verification that proves the fix (gcc 4.9.4 crashes, gcc 12+ does not) — are open source:
🔗 github.com/jvmind/jvm-deep-dives → case-01-sevenzipjbinding-aarch64-sigsegv/
The scripts are Docker-based and run end-to-end on a regular x86_64 machine (they use QEMU user-mode to run the arm64 JVM; see the repo's README for the cross-architecture setup guide). Clone it and:
cd case-01-sevenzipjbinding-aarch64-sigsegv
./verify_fix.sh # builds both toolchains, runs the crash test on each
Written after a multi-day debugging session. All disassembly, register values, and reproduction steps are verbatim from the actual investigation.
Top comments (0)