Blog

Lost in Translation: A Native Heap Overflow in Unmaintained Jansi (CVE-2026-8484)

Lev Pachmanov
Lev Pachmanov
October 6, 2026
Lost in Translation: A Native Heap Overflow in Unmaintained Jansi (CVE-2026-8484)

Jansi is the small Java package that makes colored console output work everywhere, including the Windows terminals that never understood ANSI escape codes. You may not have heard of it, but if you build Java, you almost certainly have it on disk: the Apache Maven 3.9.16 distribution puts jansi-2.4.3.jar in its lib/ directory, and Maven's pom.xml declares it as a dependency.

In June, CERT Polska disclosed a heap buffer overflow in Jansi's ioctl() wrapper (CVE-2026-8484, CERT Polska CVSS 4.0 score 4.8, medium). It affects every release through 2.4.3, which is also the last release. And the bug is not in Java at all. It is in C, which is a fun surprise for anyone who picked a JVM language for the memory safety.

How does this affect you?

Normally you would wait for a fixed release and bump the version. Here, there is nothing to bump to. In March, less than three months before the CVE was published, the maintainers merged a deprecation notice and released 2.4.3 the same day.

The README now opens with "This library is no longer maintained. It has been merged into org.jline:jansi." The CVE record says it in its own words: "This project is unmaintained at the time of CVE assignment." The official advice is to migrate to org.jline:jansi.

Whether you are exploitable, and not just vulnerable, depends on who calls the broken method. The vulnerable C code is in every copy of the jar. Jansi's own code and JLine's terminal code both call a different, safe overload, so the overflow needs a caller that passes a short int[] to the public CLibrary.ioctl method. If anything on your classpath does that, it can crash the JVM.

What even is Jansi?

Java has no portable way to ask a terminal how wide it is or whether stdout is a TTY at all. Jansi solves that by bundling native code inside the jar. The 2.4.3 jar carries fifteen prebuilt shared libraries, one per OS and architecture (Linux on seven architectures, macOS on three, FreeBSD on two, Windows on three), and loads the right one at startup.

The Java side is a thin class, org.fusesource.jansi.internal.CLibrary, full of native methods that map one-to-one onto POSIX calls: isatty, tcgetattr, openpty, and two overloads of ioctl:

public static native int ioctl(int filedes, long request, int[] params);

public static native int ioctl(int filedes, long request, WinSize params);

Each of those is implemented in C, in src/main/native/jansi.c, through JNI. JNI is the translation layer: Java hands over a typed object, C receives a raw pointer, and whatever Java knew about the object's size is only as safe as the C code that carries it across.

Size matters

Here is the int[] overload as it reads in 2.4.3:

JNIEXPORT jint JNICALL CLibrary_NATIVE(ioctl__IJ_3I)
    (JNIEnv *env, jclass that, jint arg0, jlong arg1, jintArray arg2)
{
    jint *lparg2=NULL;
    jint rc = 0;

    if (arg2) if ((lparg2 = (*env)->GetIntArrayElements(env, arg2, NULL)) == NULL) goto fail;
    rc = (jint)ioctl(arg0, arg1, lparg2);
fail:
    if (arg2 && lparg2) (*env)->ReleaseIntArrayElements(env, arg2, lparg2, 0);

    return rc;
}

GetIntArrayElements returns a pointer to the array's elements. Depending on the JVM, that is either the array itself or a copy on the native heap, but either way the buffer is exactly as large as the Java array. That pointer goes straight into ioctl().

The catch is that ioctl() has no idea how big that buffer is. The kernel decides how many bytes to write from the request, not from the argument. TIOCGWINSZ writes a struct winsize (8 bytes). On Linux, TCGETS writes the kernel's struct termios, which is 36 bytes on x86-64. The array length never enters the conversation.

Put the two together and an int[1], four bytes of buffer, asked for TCGETS, gets 36 bytes written into it. That is 32 bytes past the end of the buffer, on the heap, every call. Java's bounds checks never see it, because the write happens in the kernel on behalf of C code that Java trusted to know better.

Proof of concept

On Linux, with any pseudo-terminal file descriptor fd:

int[] params = new int[1];
CLibrary.ioctl(fd, 0x5401L /* TCGETS */, params);

Each call writes 32 bytes of terminal settings over whatever sits behind the array. Our regression test loops this call, and on the original native library it crashes the test JVM, the denial of service CERT Polska describes.

Patch work

The fix has to be in C, and it has to cope with the fact that some requests encode their argument size and some do not. Rejecting requests the wrapper cannot size would have been a breaking change, so the sealed patch keeps accepting every request and stops handing the Java array's buffer to the kernel. Trimmed to the call site (the full patch also adds the size macros and declarations):

--- a/src/main/native/jansi.c
+++ b/src/main/native/jansi.c
-   if (arg2) if ((lparg2 = (*env)->GetIntArrayElements(env, arg2, NULL)) == NULL) goto fail;
+   if (arg2) {
+       len = (*env)->GetArrayLength(env, arg2);
+       size = (size_t)len * sizeof(jint);
+       if (size < IOCTL_ARG_SIZE(arg1)) size = IOCTL_ARG_SIZE(arg1);
+       if (size < IOCTL_MIN_BUFFER_SIZE) size = IOCTL_MIN_BUFFER_SIZE;
+       if ((lparg2 = (jint *)calloc(1, size)) == NULL) {
+           errno = ENOMEM;
+           return -1;
+       }
+       (*env)->GetIntArrayRegion(env, arg2, 0, len, lparg2);
+   }
    rc = (jint)ioctl(arg0, arg1, lparg2);
-fail:
-   if (arg2 && lparg2) (*env)->ReleaseIntArrayElements(env, arg2, lparg2, 0);
+   if (arg2) {
+       (*env)->SetIntArrayRegion(env, arg2, 0, len, lparg2);
+       free(lparg2);
+   }

The kernel now writes into a zeroed scratch buffer that is at least as large as the array, the size encoded in the request (_IOC_SIZE on Linux, IOCPARM_LEN on macOS and FreeBSD), and 4096 bytes. That last floor covers legacy terminal requests like TCGETS that encode no size at all. Only the array's length is copied back, so callers that passed a correctly sized array see exactly the same results as before.

Patching a C function in a Java package also means rebuilding the binaries, which is where most of the work was. We cross-compiled the twelve native libraries that contain the wrapper (the Windows DLLs do not) with Jansi's own build targets, and kept the Linux builds within the same glibc symbol versions as upstream's so they load on the same systems. Then we ran the project's test suite plus the new overflow tests on Linux, macOS, and Windows, across Java 8, 11, and 17.

We also sent the fix upstream as fusesource/jansi#323. With the project deprecated, there is no guarantee it will be merged or released.

When a package is abandoned, there is no version to wait for. Seal Security backports the fix, native code included, into the Jansi version your application already depends on.