#1142891 libtermkey: two out-of-bounds accesses in driver-csi.c on valid CSI input

Package:
libtermkey1
Source:
libtermkey1
Description:
library for processing keyboard input
Submitter:
pavol ulicny
Date:
2026-08-16 14:53:02 UTC
Severity:
normal
Tags:
#1142891#5
Date:
2026-07-27 17:13:08 UTC
From:
To:
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
--- 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 leonerd@leonerd.org.uk 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
#1142891#10
Date:
2026-08-16 14:48:50 UTC
From:
To:
We believe that the bug you reported is fixed in the latest version of
libtermkey, which is due to be installed in the Debian FTP archive.

A summary of the changes between this version and the previous one is
attached.

Thank you for reporting the bug, which will now be closed.  If you
have further comments please address them to 1142891@bugs.debian.org,
and the maintainer will reopen the bug report if appropriate.

Debian distribution maintenance software
pp.
James McCoy <jamessan@debian.org> (supplier of updated libtermkey package)

(This message was generated automatically at their request; if you
believe that there is a problem with it please contact the archive
administrators by mailing ftpmaster@ftp-master.debian.org)
Format: 1.8
Date: Sun, 16 Aug 2026 10:26:13 -0400
Source: libtermkey
Architecture: source
Version: 0.22-3
Distribution: unstable
Urgency: medium
Maintainer: James McCoy <jamessan@debian.org>
Changed-By: James McCoy <jamessan@debian.org>
Closes: 1142891
Changes:
 libtermkey (0.22-3) unstable; urgency=medium
 .
   * Backport two patches from neovim to fix out-of-bounds accesses (Closes:
     #1142891)
   * Remove Priority: optional, since that is now the default
   * Remove Rules-Requires-Root: no, since that is now the default
   * Declare compliance with Policy 4.7.4
   * Bump debhelper compat to 14
   * Add salsa-ci.yml
   * Move to debian namespace in Salsa
Checksums-Sha1:
 09c3064d15cb5e938dd6e14d4d5fdcc8d5e4ee67 2234 libtermkey_0.22-3.dsc
 70fefa33f43d7202c96d81ce6d5942542851cb89 5596 libtermkey_0.22-3.debian.tar.xz
 6e2594c125e2534773d2dffd1c9e36a37bf4ad21 174420 libtermkey_0.22-3.git.tar.xz
 9a7ecc06e17f311fdfca31bd06fc225df600aeb9 17560 libtermkey_0.22-3_source.buildinfo
Checksums-Sha256:
 86106bc81a876c672aaab9de28d165d9ce1e5190198f9b996e1d4a93ec7655b7 2234 libtermkey_0.22-3.dsc
 49d07d53e3f2c1a42532f90925ad1f57e09f0357d4e6b3ff526cb340932eab41 5596 libtermkey_0.22-3.debian.tar.xz
 46de82d345a9dd8ccf1545295cb51679e05e8fda31b4dc3a17b001edad4dc258 174420 libtermkey_0.22-3.git.tar.xz
 7a637cbfa43b2f879b614f2346ae0e9151c0f8930b06799da9762d7c21488ae5 17560 libtermkey_0.22-3_source.buildinfo
Files:
 781d3deb91e772f8364edf04970fb0f9 2234 libs optional libtermkey_0.22-3.dsc
 51445d90e77959c558d7a44882150d48 5596 libs optional libtermkey_0.22-3.debian.tar.xz
 4a2c471059240eea7cdb1ff03d67cbb0 174420 libs None libtermkey_0.22-3.git.tar.xz
 3116555c1ddf2d6203d668397b820e50 17560 libs optional libtermkey_0.22-3_source.buildinfo
Git-Tag-Info: tag=306c36dad13789daadd44069eafe291582a2e90a fp=91bfbf4d6956bd5df7b72d23dfe691ae331ba3db
Git-Tag-Tagger: James McCoy <jamessan@debian.org>
-----BEGIN PGP SIGNATURE-----

iQIzBAEBCgAdFiEEN02M5NuW6cvUwJcqYG0ITkaDwHkFAmqBynMACgkQYG0ITkaD
wHkaSRAAlnIfdH8I0we6LG67Ir0W2S+CSnaqbAZ0QIHhdRMTM/8BN7ESaNxwbCrm
Tmx0S+53Ec0EDoBrz6C14JQZTzO8bO/mQ2Ak5H0P8P1fkqyi0xfh2XbIctTLMBiZ
f+s/EqRIoUuBSifTYVJ3pqRdgQTdaFAr+dJvkZSbpNhKfo0WdQtRDnCNjRxWjZNy
j0NdQKS4oaEZ8dSE1POA/uYzftC+us4vBHD1pGtFRgwRpZQB9ouNvKWclQKHJA9y
9lmz3yyZUligXJlZc25QU2/QuI54Nf/TceuURgWj7AO0HWAlzsS0UHqmia6s1Io5
NW3AFZpHpFDX/lIvBBHOHzRQXD3SixuivH1vaAeuBRoJ2ZEkcWjr+RgAX/vcHkVy
i09VdWdNjyYn2vGg0qbdzqa/QZEFpq+Y0OXTzOyrgJ7dFyRFM7DjhGt6P6ban3/y
ReDFztDVYqUFFHQeMB/6ByRAL6hj8eVP1ZuThsBaOc5ScH2b0wAiq2437vG8iVKe
tqJQn5CY/7be9l4DGVSaxIq+T9AKpkFBt28eGWbQWcEYM7o1XgP+VWdRxVungEMw
GqraNMRIop+MvpayohcHmpDaohXKMu4n7HZjgYP+Gw/voMQq/9iD0YR047dl8i0f
cfBqjix/gTHonc6AyXDC3wLs1jhpRMUwtJ3BUVC/PJZ6+badDuo=
=EPx6
-----END PGP SIGNATURE-----