A RAR4 archive of under 100 bytes can make Junrar, the Java RAR extraction library, spin forever on one thread. The bug, CVE-2026-97370, sits in Junrar's RAR virtual machine: one jump instruction aimed just past the end of the program skips both the jump and the loop's safety counter, so the same instruction runs again and again. Junrar fixed it in 7.6.1 on 21 July 2026. The CVE and GitHub's reviewed advisory only arrived on 15 October, and every Apache Tika release up to 3.3.2 pins an affected Junrar.
This post is for Java teams that accept archives from users, and for anyone running Tika for search, e-discovery, ingestion or AI document pipelines. It covers how the loop works, how the fix breaks it, a toy model of both, which Tika versions bundle which Junrar, three more Junrar advisories that scanners fed by advisory databases can't see, and a script that checks your build for all of them.
The short answer
CVE-2026-97370 (CVSS 7.5, high) is a CPU denial of service in Junrar up to and including 7.6.0. In RarVM.ExecuteCode, a jump to an address at or beyond the end of the VM program returns "success" without moving the instruction pointer or counting the operation, so a crafted RAR4 archive keeps the extraction thread busy forever. It's fixed in 7.6.1, released 21 July 2026, but the CVE and GitHub's reviewed advisory were published on 15 October. Apache Tika pins Junrar 7.6.0 or older in every release up to 3.3.2. Upgrade to Junrar 8.1.2, which also fixes three newer issues published only on the project's GitHub page, and put a time limit on any thread that parses untrusted archives.
How one jump freezes the VM
RAR3 and RAR4 archives can carry small programs, called filters, that run in a virtual machine during decompression. Junrar's VM gives each run a budget of 25 million operations through a counter called maxOpCount, decremented at the bottom of the execution loop. Jumps go through a helper, setIP(). Before 7.6.1 it looked like this:
private boolean setIP(int ip) {
if ((ip) >= codeSize) {
return (true);
}
if (--maxOpCount <= 0) {
return (false);
}
IP = ip;
return true;
}
When the target is at or past the end of the program, setIP() returns true without changing IP and without spending budget. The jump handler ignores the return value and jumps straight back to the top of the loop:
case VM_JMP:
setIP(getValue(false, mem, op1));
continue;
That continue also skips the IP++ and --maxOpCount at the bottom of the loop. So neither the instruction pointer nor the budget ever changes, and the same VM_JMP executes forever. The advisory credits the reporter dds921, with KeenSecurityLab as sponsor.
The proof of concept in Junrar's own test suite is a 97-byte archive. Its VM code decodes to a single VM_JMP, Junrar appends a VM_RET (so the program is two instructions long), and the jump target read from the filter's memory is 2. Two is not less than two, and the loop begins.
Junrar's advisory is clear about the limit: this is availability only. The RAR VM has no way to run native code, and Java's bounds checks keep it inside its memory array.
How 7.6.1 fixes it
The fix in commit 687a09c changes two things. setIP() now returns false for an out-of-range target, and every jump-like handler checks the result:
case VM_JMP:
if (!setIP(getValue(false, mem, op1))) break;
continue;
A failed jump now leaves the switch instead of restarting the loop, so the bottom-of-loop accounting runs, the instruction pointer moves to the VM_RET and the VM stops. On the test archive, extraction now ends with a CrcErrorException rather than hanging. The test runs under a five-second timeout.
Here's a toy model of the control flow before and after. It parses no archive and runs no Junrar code, only the logic of setIP(), the jump handler and the loop-end accounting, with a step cap standing in for a watchdog:
"""A toy model of Junrar's RarVM.ExecuteCode loop, before and after the
7.6.1 fix (commit 687a09c). It runs no archive and no real RarVM code:
just the control flow of setIP(), the jump handler and the loop-end
accounting. A step cap stands in for the watchdog you'd want in production."""
MAX_OP_COUNT = 25_000_000 # Junrar's per-run budget
STEP_CAP = 1_000_000 # lab safety net, far below the budget
def run(program, jump_target, fixed):
code_size = len(program)
state = {"ip": 0, "max_op": MAX_OP_COUNT}
def set_ip(ip):
if ip >= code_size:
return not fixed # before: True ("success"), after: False
state["max_op"] -= 1
if state["max_op"] <= 0:
return False
state["ip"] = ip
return True
steps = 0
while steps < STEP_CAP:
steps += 1
op = program[state["ip"]]
if op == "JMP":
ok = set_ip(jump_target)
if ok or not fixed: # before: result ignored, always 'continue',
continue # which skips IP++ and --maxOpCount below
# after: a failed jump 'break's out of the switch and falls through
elif op == "RET":
return f"returned after {steps} steps"
state["ip"] += 1
state["max_op"] -= 1
if state["max_op"] <= 0:
return f"stopped by maxOpCount after {steps} steps"
return (f"still looping after {steps:,} steps "
f"(ip={state['ip']}, maxOpCount={state['max_op']:,}, unchanged)")
# One VM_JMP, then the VM_RET that prepare() appends: codeSize = 2.
# The jump target resolves to 2, which is >= codeSize.
program = ["JMP", "RET"]
print("before 7.6.1:", run(program, jump_target=2, fixed=False))
print("7.6.1 and later:", run(program, jump_target=2, fixed=True))
before 7.6.1: still looping after 1,000,000 steps (ip=0, maxOpCount=25,000,000, unchanged)
7.6.1 and later: returned after 2 steps
Before the fix, a million steps in, the instruction pointer and the budget haven't moved, and the real budget of 25 million is never touched. After it, the VM returns in two steps.
The fix shipped in July, the alert in October
| Date (UTC) | Event |
|---|---|
| 21 July 2026, 05:46 | Junrar 7.6.1 on Maven Central, with the fix |
| 21 July 2026, 07:25 | Junrar publishes repository advisory GHSA-h9h9-2rmf-rvhp |
| 15 October 2026, 18:17 | CVE-2026-97370 published, assigned by GitHub |
| 15 October 2026, 20:09 | Advisory reviewed into GitHub's advisory database, which OSV mirrors |
For 86 days, the fix was public and the advisory was on GitHub, but scanners that read GitHub's reviewed database or OSV had nothing to match. The same 7.6.1 release fixed CVE-2026-86071, a directory-creation escape, whose advisory reached the reviewed database on 17 September. Our research on repository advisories that never reach the database found the same pattern across the 45 busiest advisory publishers this year.
Tika bundles Junrar, so check which one
Apache Tika's package parser depends on Junrar, and the version comes from junrar.version in Tika's parent pom. We read it for each release on Maven Central:
| Tika release | Published | Junrar pinned | CVE-2026-97370 |
|---|---|---|---|
| 2.9.4, 3.2.1 to 3.2.3 | 2025 and earlier | 7.5.5 | Affected |
| 3.3.0 | March 2026 | 7.5.8 | Affected |
| 3.3.1, 3.3.2 | May and July 2026 | 7.6.0 | Affected |
| 4.0.0 | August 2026 | 8.1.0 | Fixed |
| 4.1.0 | September 2026 | 8.1.1 | Fixed |
| 3.3.3 | 9 October 2026 | 8.1.1 | Fixed |
If you run Tika 3.3.2 or older and accept RAR files, a crafted archive can pin a parsing thread. How much that hurts depends on how you run Tika. The Tika server runs parses in a forked process with a watchdog: in 3.x, taskTimeoutMillis restarts the fork when a task runs too long, and in 4.x, progressTimeoutMillis kills a parse that stops making progress. Tika embedded in your own JVM has no such guard unless you add one.
Every Tika release in the table, including 4.1.0 and 3.3.3, pins a Junrar older than 8.1.2. And Tika's RAR parser reads each member through Archive.getInputStream(), the method one of the newer advisories covers.
Three Junrar advisories your scanner won't see
Junrar has published seven advisories in 2026. Four are in GitHub's reviewed database and OSV. Three exist only on the project's GitHub page, with no CVE, so tools fed by those databases can't match them:
| Advisory | Issue | Affected | Fixed | In OSV |
|---|---|---|---|---|
| CVE-2026-28208 | File write via backslash path traversal | before 7.5.8 | 7.5.8 | Yes |
| CVE-2026-41245 | Zip Slip via sibling directory name prefix | before 7.5.10 | 7.5.10 | Yes |
| CVE-2026-86071 | Intermediate directories created outside the extraction root | 7.6.0 and earlier | 7.6.1 | Yes |
| CVE-2026-97370 | RarVM infinite loop | 7.6.0 and earlier | 7.6.1 | Yes |
| GHSA-h76x-7cgm-p442 (31 August) | Out-of-bounds read in extended-time parsing, left over from the 7.5.1 fix | 7.5.1 and later | 8.1.1 | No |
| GHSA-frq4-6xmx-hm4g (12 October) | Archive.getInputStream swallows integrity failures, returning partial or modified bytes and a normal end of stream | before 8.1.2 | 8.1.2 | No |
| GHSA-ccq9-hw6f-p9cm (12 October) | RAR5 link extraction creates directories outside the root on Java 8 | 8.0.0 to 8.1.1 | 8.1.2 | No |
None of the three repository-only issues is in the same class as the infinite loop. The out-of-bounds read surfaces as a Java exception, not memory corruption. But the getInputStream one matters for anyone who trusts extracted bytes: the synchronous extractFile API reports the same corrupt member as invalid, while the streaming API hands it over as if nothing were wrong. We didn't test what Tika does with such a member.
Check your build
This script reads mvn dependency:tree output, Gradle lockfiles or gradle dependencies output, finds Junrar versions directly or through the Tika version that pins them, and lists every 2026 advisory that applies, marking the repository-only ones. It uses only the Python standard library and exits with status 1 if anything applies:
#!/usr/bin/env python3
"""List the 2026 Junrar advisories that apply to the junrar versions in your
build, including advisories that exist only on the project's GitHub page.
Feed it `mvn dependency:tree` output, a gradle.lockfile, or `gradle
dependencies` output: python3 junrar_check.py deps.txt gradle.lockfile
Tika versions are mapped to the junrar version their tika-parent pom pins,
for builds that only declare Tika."""
import re
import sys
# (id, fixed in, applies from, in GitHub's reviewed database?, summary)
ADVISORIES = [
("CVE-2026-28208 / GHSA-j273-m5qq-6825", "7.5.8", "0", True,
"arbitrary file write via backslash path traversal"),
("CVE-2026-41245 / GHSA-hf5p-q87m-crj7", "7.5.10", "0", True,
"Zip Slip via sibling directory name prefix"),
("CVE-2026-86071 / GHSA-89m4-43j5-vhhx", "7.6.1", "0", True,
"intermediate mkdir escapes the extraction root"),
("CVE-2026-97370 / GHSA-h9h9-2rmf-rvhp", "7.6.1", "0", True,
"RarVM VM_JMP infinite loop (CPU denial of service)"),
("GHSA-h76x-7cgm-p442", "8.1.1", "7.5.1", False,
"out-of-bounds read in extended-time parsing"),
("GHSA-frq4-6xmx-hm4g", "8.1.2", "0", False,
"Archive.getInputStream hides extraction-integrity failures"),
("GHSA-ccq9-hw6f-p9cm", "8.1.2", "8.0.0", False,
"RAR5 link extraction creates directories outside the root (Java 8)"),
]
# junrar.version in org.apache.tika:tika-parent, read from Maven Central
TIKA_JUNRAR = {
"2.9.4": "7.5.5", "3.2.1": "7.5.5", "3.2.2": "7.5.5", "3.2.3": "7.5.5",
"3.3.0": "7.5.8", "3.3.1": "7.6.0", "3.3.2": "7.6.0", "3.3.3": "8.1.1",
"4.0.0": "8.1.0", "4.1.0": "8.1.1",
}
JUNRAR = re.compile(r"com\.github\.junrar:junrar:(?:jar:)?(\d+(?:\.\d+)*)")
TIKA = re.compile(r"org\.apache\.tika:tika-[\w.-]+:(?:jar:)?(\d+\.\d+\.\d+)\b")
def key(v):
return tuple(int(p) for p in v.split("."))
def applies(version, fixed, since):
return key(since) <= key(version) < key(fixed)
def main(paths):
found = {}
for path in paths:
text = open(path, encoding="utf-8", errors="replace").read()
for v in JUNRAR.findall(text):
found.setdefault(v, set()).add(f"{path}: junrar {v}")
for t in TIKA.findall(text):
v = TIKA_JUNRAR.get(t)
label = f"{path}: Tika {t}"
if v:
found.setdefault(v, set()).add(f"{label} (pins junrar {v})")
else:
print(f"{label}: unknown Tika version, check its junrar.version")
any_hit = False
for v in sorted(found, key=key):
print(f"junrar {v} <- " + "; ".join(sorted(found[v])))
hits = [a for a in ADVISORIES if applies(v, a[1], a[2])]
for aid, fixed, _, reviewed, summary in hits:
where = "" if reviewed else " [repository advisory only, not in OSV]"
print(f" {aid}: {summary}; fixed in {fixed}{where}")
any_hit = any_hit or bool(hits)
if not hits:
print(" no 2026 advisories apply")
return 1 if any_hit else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
On two sample builds, an ingestion service on Tika 3.3.2 and a service locked to Tika 4.1.0 with Junrar 8.1.1:
junrar 7.6.0 <- fixtures/ingest-service-deps.txt: Tika 3.3.2 (pins junrar 7.6.0); fixtures/ingest-service-deps.txt: junrar 7.6.0
CVE-2026-86071 / GHSA-89m4-43j5-vhhx: intermediate mkdir escapes the extraction root; fixed in 7.6.1
CVE-2026-97370 / GHSA-h9h9-2rmf-rvhp: RarVM VM_JMP infinite loop (CPU denial of service); fixed in 7.6.1
GHSA-h76x-7cgm-p442: out-of-bounds read in extended-time parsing; fixed in 8.1.1 [repository advisory only, not in OSV]
GHSA-frq4-6xmx-hm4g: Archive.getInputStream hides extraction-integrity failures; fixed in 8.1.2 [repository advisory only, not in OSV]
junrar 8.1.1 <- fixtures/gradle.lockfile: Tika 4.1.0 (pins junrar 8.1.1); fixtures/gradle.lockfile: junrar 8.1.1
GHSA-frq4-6xmx-hm4g: Archive.getInputStream hides extraction-integrity failures; fixed in 8.1.2 [repository advisory only, not in OSV]
GHSA-ccq9-hw6f-p9cm: RAR5 link extraction creates directories outside the root (Java 8); fixed in 8.1.2 [repository advisory only, not in OSV]
A lockfile pinned to Junrar 8.1.2 prints "no 2026 advisories apply" and exits 0.
Most teams don't declare Junrar themselves. It arrives through Tika or another parser, which is why it's easy to miss. Here's Safeguard's interactive walkthrough of a dependency inventory that shows transitive packages like this one:
What to do
- Find Junrar, directly or through Tika, with the script above or
mvn dependency:tree -Dincludes=com.github.junrar. - Upgrade to Junrar 8.1.2, or override the version Tika brings in (a
dependencyManagemententry in Maven, a constraint in Gradle). Moving from 7.x to 8.x is a major version: 8.0.0 removedUnsupportedRarV5Exception, so code that catches it should catchRarExceptioninstead. - Upgrade Tika to 3.3.3 or 4.1.0 if you can't override Junrar, and still pin Junrar 8.1.2 on top, since both ship 8.1.1.
- Put a deadline on archive parsing. Run extraction on a worker with a time limit, or use the Tika server's timeouts. This turns the next infinite loop into a failed job instead of a stuck one.
- Watch the project's advisory page, not just your scanner. Three of Junrar's seven 2026 advisories aren't in the databases scanners use.
The fair view
Junrar's maintainers handled this well. The fix, a regression test archive and the repository advisory went out on the same day, and later releases kept closing related issues, including the three that never got CVEs. The impact is availability only, and the Tika server's watchdog limits the damage for teams that run it that way.
The gap is downstream. A fix that's public in July but only visible to scanners in October leaves anyone relying on a scan for those 86 days without a warning. Tika users had an extra hop: the fix had to reach Tika's pom and then a Tika release, which took until August on the 4.x line and October on 3.x. For libraries that parse untrusted input and sit several layers deep in your build, reading the project's own advisories is the only way to be on time.
Check yourself
Why doesn't the 25-million-operation budget stop the loop?
Because neither path that spends it runs. setIP() returns early for an out-of-range target before decrementing maxOpCount, and the handler's continue skips the decrement at the bottom of the loop. The counter stays at its starting value forever.
You run Tika 4.1.0. Are you affected by CVE-2026-97370?
No. Tika 4.1.0 pins Junrar 8.1.1, and the fix landed in 7.6.1. But 8.1.1 is still affected by two repository-only advisories fixed in 8.1.2, including the getInputStream one, so pin Junrar 8.1.2.
Your scanner reported nothing for Junrar 7.6.0 in August. Was the build safe?
No. The fix and the repository advisory were public on 21 July, but the advisory only reached GitHub's reviewed database and OSV on 15 October, so database-fed scanners had nothing to match until then.