Package: libtermkey1
Version: 0.22-2
Severity: important
Tags: upstream patch

Dear Maintainer,

driver-csi.c has two out-of-bounds accesses reachable from termkey_getkey()
on
well-formed CSI input. Both are one-line fixes, and both have already been
fixed
in Neovim's vendored copy of this code; upstream libtermkey and every Debian
suite still carry them.

I am reporting them together because they are adjacent defects in the same
file
with the same provenance, and applying both is a single upload.

Both patches are attached, and also reproduced inline below so they can be
read
in context. If the inline copies have picked up any whitespace damage in
transit,
please treat the attachments as authoritative.

Neither is novel, and I want to be upfront about that rather than have you
find
it: bug 2 has been publicly known since 2023 and bug 1 since June 2026, but
both
reports were filed against Neovim, not against upstream libtermkey or
Debian.
There is no bug open against this package (I checked; the BTS reports
none), so
the fixes have never made it back to the users who get libtermkey from the
archive.

I have not tagged this "security" and I am not requesting a CVE; see IMPACT
below for why. Please retag if you disagree.


BUG 1: out-of-bounds read in handle_csi_ss3_full()

Five bytes crash any application that feeds terminal input to
termkey_getkey():

    ESC [ 1 SP A          1b 5b 31 20 41

That is CSI 1 SP A, xterm's SR (Shift right): parameter byte '1' (0x31),
intermediate byte SP (0x20, legal range 0x20-0x2F), final byte 'A' (0x41,
legal
range 0x40-0x7E). Valid input, not malformed garbage.

The most direct demonstration is that Ubuntu's packaged neovim, which links
this
package's shared library, segfaults on those five bytes:

    $ ldd /usr/bin/nvim | grep termkey
        libtermkey.so.1 => /lib/x86_64-linux-gnu/libtermkey.so.1
    $ printf '\033[1 A' | script -qec "/usr/bin/nvim -u NONE -i NONE"
/dev/null
    $ echo $?
    139

(That is neovim 0.9.5-6ubuntu2 on noble. Current Debian neovim no longer
links
this library -- see EXPOSURE below -- so this particular demonstration does
not
apply to trixie or sid. It is included because it is an unmodified archive
package crashing on five bytes of input, not because neovim is the main
consumer.)

Reduced to the public API only, compiling no libtermkey sources, so that
linking
against the packaged shared object shows the fault is in the library and
not the
caller:

    $ cat > repro.c <<'EOF'
    #include <termkey.h>
    #include <string.h>
    int main(void)
    {
        static const char seq[] = "\033[1 A";
        TermKey *tk = termkey_new_abstract("xterm", 0);
        termkey_push_bytes(tk, seq, sizeof(seq) - 1);
        TermKeyKey key;
        while (termkey_getkey(tk, &key) == TERMKEY_RES_KEY) { }
        termkey_destroy(tk);
        return 0;
    }
    EOF
    $ gcc -std=c99 repro.c -o repro -ltermkey
    $ ./repro; echo "exit=$?"
    Segmentation fault (core dumped)
    exit=139

Upstream's own demo.c, built unmodified against the packaged library and
given
those bytes on a real pty, dies the same way.

valgrind against the packaged shared object:

    ==53597== Invalid read of size 4
    ==53597==    at 0x487EADE: ??? (in
/usr/lib/x86_64-linux-gnu/libtermkey.so.1.14.2)
    ==53597==    by 0x487F7F8: ??? (in
/usr/lib/x86_64-linux-gnu/libtermkey.so.1.14.2)
    ==53597==    by 0x487CAA1: ??? (in
/usr/lib/x86_64-linux-gnu/libtermkey.so.1.14.2)
    ==53597==    by 0x487D479: termkey_getkey (in
/usr/lib/x86_64-linux-gnu/libtermkey.so.1.14.2)
    ==53597==  Address 0x6885310 is not stack'd, malloc'd or (recently)
free'd

A source build with ASan/UBSan names the line:

    driver-csi.c:34:15: runtime error: index 2097153 out of bounds for type
'struct keyinfo[64]'
    AddressSanitizer: SEGV on unknown address
        #0 handle_csi_ss3_full driver-csi.c:34
        #1 peekkey_csi         driver-csi.c:567
        #2 peekkey             driver-csi.c:718
        #3 termkey_getkey      termkey.c:1040

Cause. parse_csi() packs the whole CSI command into one integer: final byte
in
bits 0-7, initial/private byte in bits 8-15, intermediate byte in bits
16-23.
peekkey_csi() knows this and masks when it dispatches, but passes the
unmasked
value to the handler (driver-csi.c:566):

    // We know from the logic above that cmd must be >= 0x40 and < 0x80
    if(csi_handlers[(cmd & 0xff) - 0x40])
      result = (*csi_handlers[(cmd & 0xff) - 0x40])(tk, key, cmd, arg,
args);

The comment holds only for the masked value. handle_csi_ss3_full() then
indexes
a 64-element array with the unmasked one, with no bounds check:

    key->type     = csi_ss3s[cmd - 0x40].type;
    key->code.sym = csi_ss3s[cmd - 0x40].sym;

For CSI 1 SP A, cmd = 0x41 | (0x20 << 16) = 0x200041, giving index 2097153;
sizeof(struct keyinfo) is 16, so the read lands 32 MB past the array.

The trigger is a family, not a single sequence. An initial byte alone is
enough,
which matters because CSI sequences starting with '?' are ordinary terminal
replies rather than exotic input:

    CSI ? A         1b 5b 3f 41              index 16129     SIGSEGV
    CSI < Z         1b 5b 3c 5a              index 15386     SIGSEGV
    CSI ? 6 ; 1 $ R 1b 5b 3f 36 3b 31 24 52                  SIGSEGV
    CSI 1 $ P       1b 5b 31 24 50                           SIGSEGV
    CSI 1 SP R      1b 5b 31 20 52                           SIGSEGV

So an application that queries the terminal can be crashed by the terminal's
reply. That is how the Neovim reporter hit it: their terminal emulator sent
a
malformed status response.

That this is an oversight rather than a contract is clear from the same
file.
The registration counterpart enforces exactly the missing invariant:

    static void register_csi_ss3_full(..., unsigned char cmd)
    {
      if(cmd < 0x40 || cmd >= 0x80) {
        return;
      }

and so does the SS3 read path at driver-csi.c:615, byte for byte:

    if(cmd < 0x40 || cmd >= 0x80)
      return TERMKEY_RES_NONE;

Fix: inline below, and attached as fix.patch. It is the guard Neovim added
in
their vendored copy in June 2026 (neovim/neovim#40296, commit cd72e130),
which
also corrects the misleading comment quoted above.

--- a/driver-csi.c
+++ b/driver-csi.c
@@ -26,6 +26,13 @@

 static TermKeyResult handle_csi_ss3_full(TermKey *tk, TermKeyKey *key, int
cmd, long *arg, int args)
 {
+  /* peekkey_csi() dispatches on (cmd & 0xff) but passes the unmasked cmd,
which
+   * carries the CSI initial and intermediate bytes in bits 8-15 and
16-23. Only
+   * the low byte is known to be in 0x40-0x7E, so reject anything else
rather
+   * than index csi_ss3s[] out of bounds. */
+  if(cmd < 0x40 || cmd >= 0x80)
+    return TERMKEY_RES_NONE;
+
   if(args > 1 && arg[1] != -1)
     key->modifiers = arg[1] - 1;
   else

TERMKEY_RES_NONE is the right result: the sequence really is unrecognised,
and
callers already handle that. Masking instead would stop the crash but would
report CSI 1 SP A as an Up arrow, silently aliasing every
intermediate-carrying
sequence onto an unrelated key.

handle_csi_ss3_full() is the only unguarded sink -- I checked the other CSI
handlers. handle_csifunc() bounds-checks its index, handle_csi_u() and
handle_csi_y() switch on cmd and fall through to TERMKEY_RES_NONE,
handle_csi_m()
masks, and handle_csi_R() delegates to handle_csi_ss3_full(), so it is
covered by
the same fix.


BUG 2: out-of-bounds write in parse_csi()

peekkey_csi() gives parse_csi() a 16-element array (driver-csi.c:533):

    long arg[16];

and parse_csi() bounds it one slot too late (driver-csi.c:381):

    present = 0;
    argi++;

    if(argi > 16)
      break;

argi reaches 16 and the digit branch above then writes args[16], eight
bytes past
the end of the caller's stack array. The value is whatever decimal number
the
input supplies, so it is attacker-controlled. The overflow is bounded to
that one
slot: on the next ';' argi becomes 17 and the loop breaks.

Trigger: any CSI sequence carrying 17 or more parameters.

    ESC [ 1;2;3;4;5;6;7;8;9;10;11;12;13;14;15;16;9999999999999999 R

ASan on a pristine 0.22 source build:

    ERROR: AddressSanitizer: stack-buffer-overflow
    WRITE of size 8 at 0x79acd14000e0
        #0 parse_csi      driver-csi.c:368
        #1 peekkey_csi    driver-csi.c:536
        #2 peekkey        driver-csi.c:718
        #3 termkey_getkey termkey.c:1040

      [96, 224) 'arg' (line 533) <== Memory access at offset 224 overflows
this variable

Note on evidence: this one does not segfault on the packaged shared object
here
-- the stray write lands in stack padding and the process exits 0 -- and
valgrind
is structurally blind to it, since it does not track overflows that stay
inside
one stack frame. ASan is the only tool that observes it. An exit status of
0 from
the reproducer therefore does not indicate the library is unaffected.

It does bite in the field, though. neovim/neovim#24356 (2023-07-15) reports

    *** stack smashing detected ***: terminated
    Aborted (core dumped)

on startup with the RLogin terminal, which replies to the "CSI c" device
attributes query with 17 parameters. That report identifies the cause as
libtermkey's driver-csi.c:533 -- i.e. this package's code, not something
specific
to Neovim.

Fix: inline below, and attached as fix-oob-write.patch. Same change Neovim
made
in May 2025 (neovim/neovim#33868, commit 8707ec26): argi > 16 becomes argi
>= 16.

--- a/driver-csi.c
+++ b/driver-csi.c
@@ -378,7 +378,11 @@
       present = 0;
       argi++;

-      if(argi > 16)
+      /* args[] has 16 elements, so argi is only a valid index while it is
+       * below 16. Breaking at "> 16" lets argi reach 16 and the digit
branch
+       * above then writes args[16], one element past the end of the
caller's
+       * array. */
+      if(argi >= 16)
         break;
     }
     else if(c >= 0x20 && c <= 0x2f) {


IMPACT

Bug 1 is an out-of-bounds read, not a write. The values read populate
key->type,
key->code.sym and the modifier fields; they are not returned to the
attacker, so
this is not a useful information disclosure. With an intermediate byte the
offset
is 32-47 MB out and reliably unmapped, so the process dies. With only an
initial
byte the offset is a few hundred KB out and may be mapped, in which case the
caller silently gets a bogus key event derived from unrelated memory
instead of
crashing.

Bug 2 is an out-of-bounds write, which sounds worse and in some respects
is, but
I do not want to overstate it: it is a single 8-byte slot immediately past a
128-byte array, it cannot walk further, and on any hardened build the stack
canary sits between it and the return address, so the realistic outcome is
the
controlled abort seen in #24356. I was not able to make it do anything
beyond
that.

So: denial of service for both, which is why I have not tagged this
security or
asked for a CVE. The argument for fixing them is less that they are
exploitable
and more that a terminal input parser should not have undefined behaviour on
input a terminal can legitimately produce.

On reachability I want to be careful rather than expansive, since it is
easy to
overstate. libtermkey parses the application's input, so bytes the terminal
merely displays do not reach it: cat'ing a file full of escape sequences,
or a
remote peer printing them, is output and lands on the screen, not in
termkey_getkey(). Those become an input path only if they provoke a terminal
reply, which is written back to the tty input side.

The paths that do reach the parser are the terminal's replies to queries the
application itself makes (the demonstrated one for both bugs -- a malformed
status response for bug 1, RLogin's 17-parameter "CSI c" answer for bug 2),
text
pasted into the terminal, and injection into the tty input queue by a
process
with the access to do it.

So the realistic victim is a user whose terminal emits something unusual,
not a
targeted one. I think that argues for fixing it as a robustness bug rather
than
treating it as a security issue, which is the other reason I have not
tagged it
as such.


AFFECTED VERSIONS

Both bugs are present in every released version: 0.19, 0.20, 0.21 and 0.22.
I
built each upstream tarball and ran the reproducers against it rather than
only
reading the source. That covers 0.19-1 in stretch, 0.20-3 in buster, 0.22-1
in
bullseye and bookworm, and 0.22-2 in trixie, forky and sid. The package
carries
no patch touching either site (debian/patches holds only
makefile-use-pkg_config-if-set-to-run-pk.patch).

Neovim's vendored fork has both fixes and is not affected. It is worth
noting
that it was not always so: Neovim vendored this code in September 2024
carrying
both bugs, and fixed them separately in May 2025 and June 2026.


EXPOSURE

Binary packages depending on libtermkey1: vis, libtickit3t64,
libterm-termkey-perl, libtermkey-dev, and neovim in the older suites.

To be accurate about neovim: it dropped the libtermkey build-dependency and
now
uses its own vendored copy, so neovim in trixie (0.10.4-8) and sid
(0.12.4-1) is
not exposed through this package. The crash shown above is Ubuntu noble's
0.9.5
and applies to the older suites. Current Debian exposure through
libtermkey1 is
vis, libtickit3t64 and libterm-termkey-perl.


TESTING

Upstream's own test suite, 16 test files and 430 assertions, passes
identically
before and after both patches: 430 ok, 0 not ok in each case.
(40ti-override is
excluded because the terminfo driver needs unibilium, which is unrelated to
either change.) Both reproducers exit cleanly with the patches applied,
including
the ASan build for bug 2.


UPSTREAM

I could not find a working upstream channel. The project page at
https://www.leonerd.org.uk/code/libtermkey/ still shows 0.22 as current,
was last
touched in 2019, sits in Bazaar and lists no bug tracker. There is no
Launchpad
project for libtermkey (the author has one for libtickit but not for this),
and
the neovim/libtermkey GitHub mirror was archived on 2023-12-13 with issues
disabled. I am sending a copy of this report to [email protected] as a
courtesy.

REFERENCES

  neovim/neovim#40295, #40296, commit cd72e130  -- bug 1, fixed 2026-06-18
  neovim/neovim#24356, #33868, commit 8707ec26  -- bug 2, fixed 2025-05-06

-- Testing environment:

In the interest of not wasting your time: I am reporting from an Ubuntu
machine,
not a Debian one, so there is no reportbug system block. Concretely, what I
tested was:

  - libtermkey1 0.22-1 as shipped in Ubuntu noble -- this is the binary used
    for the reproducer, the neovim crash and the valgrind run above
  - the upstream 0.22 tarball from leonerd.org.uk -- the sources used for
the
    ASan runs, and what both patches are against
  - libunibilium4 2.1.0-3

I have not run 0.22-2 itself. It carries one Debian patch,
makefile-use-pkg_config-if-set-to-run-pk.patch, which does not touch
driver-csi.c, so both defects are present in it as shipped; but you should
treat
the version field as "affects" rather than "tested" and I did not want to
imply
otherwise.
--- a/driver-csi.c	2026-07-27 17:44:24.285248923 +0200
+++ b/driver-csi.c	2026-07-27 17:44:24.301166091 +0200
@@ -26,6 +26,13 @@
 
 static TermKeyResult handle_csi_ss3_full(TermKey *tk, TermKeyKey *key, int cmd, long *arg, int args)
 {
+  /* peekkey_csi() dispatches on (cmd & 0xff) but passes the unmasked cmd, which
+   * carries the CSI initial and intermediate bytes in bits 8-15 and 16-23. Only
+   * the low byte is known to be in 0x40-0x7E, so reject anything else rather
+   * than index csi_ss3s[] out of bounds. */
+  if(cmd < 0x40 || cmd >= 0x80)
+    return TERMKEY_RES_NONE;
+
   if(args > 1 && arg[1] != -1)
     key->modifiers = arg[1] - 1;
   else
--- a/driver-csi.c	2026-07-27 18:28:55.285907964 +0200
+++ b/driver-csi.c	2026-07-27 18:28:55.298272733 +0200
@@ -378,7 +378,11 @@
       present = 0;
       argi++;
 
-      if(argi > 16)
+      /* args[] has 16 elements, so argi is only a valid index while it is
+       * below 16. Breaking at "> 16" lets argi reach 16 and the digit branch
+       * above then writes args[16], one element past the end of the caller's
+       * array. */
+      if(argi >= 16)
         break;
     }
     else if(c >= 0x20 && c <= 0x2f) {

Reply via email to