Safeguard
Vulnerability Analysis•

Junrar CVE-2026-97370: One Jump Instruction Hangs RAR Extraction, and Tika Bundled It

A RAR4 archive under 100 bytes can pin a Junrar extraction thread forever: an out-of-range jump in the RAR VM skips both the jump and the operation budget. Fixed in 7.6.1 on 21 July, published as CVE-2026-97370 on 15 October. How the loop and fix work, which Apache Tika releases bundle an affected Junrar, three repository-only advisories scanners miss, and a build checker.

Safeguard Security Team
Security Research
14 min read

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:46Junrar 7.6.1 on Maven Central, with the fix
21 July 2026, 07:25Junrar publishes repository advisory GHSA-h9h9-2rmf-rvhp
15 October 2026, 18:17CVE-2026-97370 published, assigned by GitHub
15 October 2026, 20:09Advisory 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 releasePublishedJunrar pinnedCVE-2026-97370
2.9.4, 3.2.1 to 3.2.32025 and earlier7.5.5Affected
3.3.0March 20267.5.8Affected
3.3.1, 3.3.2May and July 20267.6.0Affected
4.0.0August 20268.1.0Fixed
4.1.0September 20268.1.1Fixed
3.3.39 October 20268.1.1Fixed

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:

AdvisoryIssueAffectedFixedIn OSV
CVE-2026-28208File write via backslash path traversalbefore 7.5.87.5.8Yes
CVE-2026-41245Zip Slip via sibling directory name prefixbefore 7.5.107.5.10Yes
CVE-2026-86071Intermediate directories created outside the extraction root7.6.0 and earlier7.6.1Yes
CVE-2026-97370RarVM infinite loop7.6.0 and earlier7.6.1Yes
GHSA-h76x-7cgm-p442 (31 August)Out-of-bounds read in extended-time parsing, left over from the 7.5.1 fix7.5.1 and later8.1.1No
GHSA-frq4-6xmx-hm4g (12 October)Archive.getInputStream swallows integrity failures, returning partial or modified bytes and a normal end of streambefore 8.1.28.1.2No
GHSA-ccq9-hw6f-p9cm (12 October)RAR5 link extraction creates directories outside the root on Java 88.0.0 to 8.1.18.1.2No

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

  1. Find Junrar, directly or through Tika, with the script above or mvn dependency:tree -Dincludes=com.github.junrar.
  2. Upgrade to Junrar 8.1.2, or override the version Tika brings in (a dependencyManagement entry in Maven, a constraint in Gradle). Moving from 7.x to 8.x is a major version: 8.0.0 removed UnsupportedRarV5Exception, so code that catches it should catch RarException instead.
  3. 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.
  4. 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.
  5. 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.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.