Fix timing, memory safety, and tar extraction vulnerabilities #9802
Summary
Found and fixed several security issues across the C, Python, and Go code during a source audit. All changes are minimal and surgical.
Timing-safe comparisons
- crypto.c —
Secret.__eq__usedmemcmp(not constant-time) and compared onlymin(len_a, len_b)bytes, so secrets of different lengths with a shared prefix compared equal. Switched toCRYPTO_memcmpwith a length-equality check first. - remote_control.py — Remote control passwords were looked up via
dict.get(), which leaks timing through Python's hash+compare. Replaced with a constant-time linear scan usinghmac.compare_digest. - file_transmission.py — Bypass token comparison in
check_bypassused==for both thekitty-1andsha256protocols. Switched tohmac.compare_digest.
Memory safety (C)
- child-monitor.c —
write_to_peerhad an inverted condition (n > usedinstead ofn < used). Sincesend()never returns more thanused, thememmovenever fired, so partial writes resent stale data from the buffer head. - ibus_glfw.c —
IBUS_ADDRESSenv var copied viamemcpywithout a null terminator. Whenstrlen(addr) >= PATH_MAX, the static buffer was returned unterminated. - x11_window.c — Two
realloccalls (clipboard + DnD chunked writer) didn't check forNULL, leading to a use-after-free of the old pointer and amemcpyintoNULL + offset. - dnd.c —
accepted_mimesbuffer had no size cap (unlikeregistered_mimeswhich caps at 1MB) and thereallocpattern leaked the old pointer on failure. Added the same 1MB cap and safe realloc. - png-reader.c —
rowbytes * heightwas computed aspng_uint_32arithmetic, which can overflow on 32-bit before assigning tosize_t. Added explicit casts.
Secrets hygiene
- disk-cache.c —
CacheValuecontains a 64-byte encryption key that was left in memory afterfree(). Addedexplicit_bzerobefore freeing.
Tar extraction hardening
- tar.go — Hardlink targets were not validated against the destination prefix the way
destand symlinks were, allowing a malicious tar to hardlink outside the extraction directory then overwrite the target via a later entry. Also stripped setuid/setgid/sticky bits from extracted file modes.
Test plan
- Full build (
make) passes with zero errors/warnings - Full test suite (
make test) passes — 2 pre-existing font-name failures unrelated to these changes - Python files pass
py_compileandast.parse - Go files pass
gofmt -e - Upstream CI
glfw/ibus_glfw.c +3 -1
| ··· | @@ -287,7 +287,9 @@ get_ibus_address_file_name(void) { | ||
| 287 | 287 | addr = getenv("IBUS_ADDRESS"); | |
| 288 | 288 | int offset = 0; | |
| 289 | 289 | if (addr && addr[0]) { | |
| 290 | - | memcpy(ans, addr, GLFW_MIN(strlen(addr), sizeof(ans))); | |
| 290 | + | size_t len = GLFW_MIN(strlen(addr), sizeof(ans) - 1); | |
| 291 | + | memcpy(ans, addr, len); | |
| 292 | + | ans[len] = '\0'; | |
| 291 | 293 | return ans; | |
| 292 | 294 | } | |
| 293 | 295 | const char* disp_num = NULL; | |
glfw/x11_window.c +6 -2
| ··· | @@ -937,7 +937,9 @@ get_clipboard_data(const _GLFWClipboardData *cd, const char *mime, char **data) | ||
| 937 | 937 | if (!chunk.sz) break; | |
| 938 | 938 | if (cap < sz + chunk.sz) { | |
| 939 | 939 | cap = MAX(cap * 2, sz + 4 * chunk.sz); | |
| 940 | - | buf = realloc(buf, cap * sizeof(buf[0])); | |
| 940 | + | char *new_buf = realloc(buf, cap * sizeof(buf[0])); | |
| 941 | + | if (!new_buf) { free(buf); *data = NULL; cd->get_data(NULL, iter, cd->ctype); return 0; } | |
| 942 | + | buf = new_buf; | |
| 941 | 943 | } | |
| 942 | 944 | memcpy(buf + sz, chunk.data, chunk.sz); | |
| 943 | 945 | sz += chunk.sz; | |
| ··· | @@ -3636,7 +3638,9 @@ write_chunk(void *object, const char *data, size_t sz) { | ||
| 3636 | 3638 | if (data) { | |
| 3637 | 3639 | if (cw->cap < cw->sz + sz) { | |
| 3638 | 3640 | cw->cap = MAX(cw->cap * 2, cw->sz + 8*sz); | |
| 3639 | - | cw->buf = realloc(cw->buf, cw->cap * sizeof(cw->buf[0])); | |
| 3641 | + | char *new_buf = realloc(cw->buf, cw->cap * sizeof(cw->buf[0])); | |
| 3642 | + | if (!new_buf) return false; | |
| 3643 | + | cw->buf = new_buf; | |
| 3640 | 3644 | } | |
| 3641 | 3645 | memcpy(cw->buf + cw->sz, data, sz); | |
| 3642 | 3646 | cw->sz += sz; | |
kitty/child-monitor.c +1 -1
| ··· | @@ -1940,7 +1940,7 @@ write_to_peer(Peer *peer) { | ||
| 1940 | 1940 | else if (n < 0) { | |
| 1941 | 1941 | if (errno != EINTR) { log_error("write() to peer socket failed with error: %s", strerror(errno)); peer->write.used = 0; peer->write.failed = true; } | |
| 1942 | 1942 | } else { | |
| 1943 | - | if ((size_t)n > peer->write.used) memmove(peer->write.data, peer->write.data + n, peer->write.used - n); | |
| 1943 | + | if ((size_t)n < peer->write.used) memmove(peer->write.data, peer->write.data + n, peer->write.used - n); | |
| 1944 | 1944 | peer->write.used -= n; | |
| 1945 | 1945 | } | |
| 1946 | 1946 | talk_mutex(unlock); | |
kitty/crypto.c +3 -2
| ··· | @@ -14,6 +14,7 @@ | ||
| 14 | 14 | #include <openssl/pem.h> | |
| 15 | 15 | #include <openssl/bio.h> | |
| 16 | 16 | #include <openssl/rand.h> | |
| 17 | + | #include <openssl/crypto.h> | |
| 17 | 18 | #include <sys/mman.h> | |
| 18 | 19 | #include <structmember.h> | |
| 19 | 20 | ||
| ··· | @@ -72,8 +73,8 @@ dealloc_secret(Secret *self) { | ||
| 72 | 73 | ||
| 73 | 74 | static int | |
| 74 | 75 | __eq__(Secret *a, Secret *b) { | |
| 75 | - | const size_t l = a->secret_len < b->secret_len ? a->secret_len : b->secret_len; | |
| 76 | - | return memcmp(a->secret, b->secret, l) == 0; | |
| 76 | + | if (a->secret_len != b->secret_len) return 0; | |
| 77 | + | return CRYPTO_memcmp(a->secret, b->secret, a->secret_len) == 0; | |
| 77 | 78 | } | |
| 78 | 79 | ||
| 79 | 80 | static Py_ssize_t | |
kitty/disk-cache.c +2 -1
| ··· | @@ -16,6 +16,7 @@ | ||
| 16 | 16 | #include "cross-platform-random.h" | |
| 17 | 17 | #include <structmember.h> | |
| 18 | 18 | #include <stdlib.h> | |
| 19 | + | #include <string.h> | |
| 19 | 20 | #include <sys/stat.h> | |
| 20 | 21 | #include <fcntl.h> | |
| 21 | 22 | #include <time.h> | |
| ··· | @@ -40,7 +41,7 @@ static uint64_t key_hash(KEY_TY k); | ||
| 40 | 41 | #define HASH_FN key_hash | |
| 41 | 42 | static bool keys_are_equal(CacheKey a, CacheKey b) { return a.hash_keylen == b.hash_keylen && memcmp(a.hash_key, b.hash_key, a.hash_keylen) == 0; } | |
| 42 | 43 | #define CMPR_FN keys_are_equal | |
| 43 | - | static void free_cache_value(CacheValue *cv) { free(cv->data); cv->data = NULL; free(cv); } | |
| 44 | + | static void free_cache_value(CacheValue *cv) { explicit_bzero(cv->encryption_key, sizeof(cv->encryption_key)); free(cv->data); cv->data = NULL; free(cv); } | |
| 44 | 45 | static void free_cache_key(CacheKey cv) { free(cv.hash_key); cv.hash_key = NULL; } | |
| 45 | 46 | #define KEY_DTOR_FN free_cache_key | |
| 46 | 47 | #define VAL_DTOR_FN free_cache_value | |
kitty/dnd.c +6 -5
| ··· | @@ -308,11 +308,12 @@ drop_set_status(Window *w, int operation, const char *payload, size_t payload_sz | ||
| 308 | 308 | } | |
| 309 | 309 | } | |
| 310 | 310 | if (payload_sz) { | |
| 311 | - | w->drop.accepted_mimes = realloc(w->drop.accepted_mimes, w->drop.accepted_mimes_sz + payload_sz + 2); | |
| 312 | - | if (w->drop.accepted_mimes) { | |
| 313 | - | memcpy(w->drop.accepted_mimes + w->drop.accepted_mimes_sz, payload, payload_sz); | |
| 314 | - | w->drop.accepted_mimes_sz += payload_sz; | |
| 315 | - | } | |
| 311 | + | if (w->drop.accepted_mimes_sz + payload_sz > 1024 * 1024) return; | |
| 312 | + | char *new_buf = realloc(w->drop.accepted_mimes, w->drop.accepted_mimes_sz + payload_sz + 2); | |
| 313 | + | if (!new_buf) return; | |
| 314 | + | w->drop.accepted_mimes = new_buf; | |
| 315 | + | memcpy(w->drop.accepted_mimes + w->drop.accepted_mimes_sz, payload, payload_sz); | |
| 316 | + | w->drop.accepted_mimes_sz += payload_sz; | |
| 316 | 317 | } | |
| 317 | 318 | if (!more) { | |
| 318 | 319 | w->drop.accept_in_progress = false; | |
kitty/file_transmission.py +3 -2
| ··· | @@ -2,6 +2,7 @@ | ||
| 2 | 2 | # License: GPLv3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net> | |
| 3 | 3 | ||
| 4 | 4 | import errno | |
| 5 | + | import hmac | |
| 5 | 6 | import inspect | |
| 6 | 7 | import io | |
| 7 | 8 | import json | |
| ··· | @@ -572,12 +573,12 @@ def check_bypass(password: str, request_id: str, bypass_data: str) -> bool: | ||
| 572 | 573 | delta = time_ns() - int(timestamp) | |
| 573 | 574 | if abs(delta) > 5 * 60 * 1e9: | |
| 574 | 575 | return False | |
| 575 | - | return payload == f'{request_id};{password}' | |
| 576 | + | return hmac.compare_digest(payload, f'{request_id};{password}') | |
| 576 | 577 | except Exception as err: | |
| 577 | 578 | log_error(f'Invalid file transmission bypass data received: {err}') | |
| 578 | 579 | return False | |
| 579 | 580 | elif protocol == 'sha256': | |
| 580 | - | return (encode_bypass(request_id, password) == bypass_data) if password else False | |
| 581 | + | return hmac.compare_digest(encode_bypass(request_id, password), bypass_data) if password else False | |
| 581 | 582 | else: | |
| 582 | 583 | log_error(f'Invalid file transmission bypass data received with protocol: {protocol}') | |
| 583 | 584 | return False | |
kitty/png-reader.c +1 -1
| ··· | @@ -113,7 +113,7 @@ inflate_png_inner(png_read_data *d, const uint8_t *buf, size_t bufsz, int max_im | ||
| 113 | 113 | png_read_update_info(png, info); | |
| 114 | 114 | ||
| 115 | 115 | png_uint_32 rowbytes = png_get_rowbytes(png, info); | |
| 116 | - | d->sz = sizeof(png_byte) * rowbytes * d->height; | |
| 116 | + | d->sz = (size_t)rowbytes * (size_t)d->height; | |
| 117 | 117 | d->decompressed = malloc(d->sz + 16); | |
| 118 | 118 | if (d->decompressed == NULL) ABRT(ENOMEM, "Out of memory allocating decompression buffer for PNG"); | |
| 119 | 119 | d->row_pointers = malloc(d->height * sizeof(png_bytep)); | |
kitty/remote_control.py +12 -3
| ··· | @@ -2,6 +2,7 @@ | ||
| 2 | 2 | # License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net> | |
| 3 | 3 | ||
| 4 | 4 | import base64 | |
| 5 | + | import hmac | |
| 5 | 6 | import json | |
| 6 | 7 | import os | |
| 7 | 8 | import re | |
| ··· | @@ -103,14 +104,22 @@ def fnmatch_pattern(pat: str) -> 're.Pattern[str]': | ||
| 103 | 104 | return re.compile(translate(pat)) | |
| 104 | 105 | ||
| 105 | 106 | ||
| 107 | + | def _ct_password_lookup(passwords: dict[str, Any], pw: str) -> Any: | |
| 108 | + | result = None | |
| 109 | + | for k, v in passwords.items(): | |
| 110 | + | if hmac.compare_digest(k, pw): | |
| 111 | + | result = v | |
| 112 | + | return result | |
| 113 | + | ||
| 114 | + | ||
| 106 | 115 | def remote_control_allowed( | |
| 107 | 116 | pcmd: dict[str, Any], remote_control_passwords: dict[str, Sequence[str]] | None, | |
| 108 | 117 | window: Optional['Window'], extra_data: dict[str, Any] | |
| 109 | 118 | ) -> bool: | |
| 110 | 119 | if not remote_control_passwords: | |
| 111 | 120 | return True | |
| 112 | 121 | pw = pcmd.get('password', '') | |
| 113 | - | auth_items = remote_control_passwords.get(pw) | |
| 122 | + | auth_items = _ct_password_lookup(remote_control_passwords, pw) | |
| 114 | 123 | if pw == '!': | |
| 115 | 124 | auth_items = None | |
| 116 | 125 | if auth_items is None: | |
| ··· | @@ -185,10 +194,10 @@ def is_cmd_allowed(pcmd: dict[str, Any], window: Optional['Window'], from_socket | ||
| 185 | 194 | return False | |
| 186 | 195 | pa = password_authorizer(auth_items) | |
| 187 | 196 | return pa.is_cmd_allowed(pcmd, window, from_socket, extra_data) | |
| 188 | - | q = user_password_allowed.get(pw) | |
| 197 | + | q = _ct_password_lookup(user_password_allowed, pw) | |
| 189 | 198 | if q is not None: | |
| 190 | 199 | return q | |
| 191 | - | auth_items = get_options().remote_control_password.get(pw) | |
| 200 | + | auth_items = _ct_password_lookup(get_options().remote_control_password, pw) | |
| 192 | 201 | if auth_items is None: | |
| 193 | 202 | return None | |
| 194 | 203 | pa = password_authorizer(auth_items) | |
tools/utils/tar.go +7 -1
| ··· | @@ -182,7 +182,7 @@ func ExtractAllFromTar(tr *tar.Reader, dest_path string, optss ...TarExtractOpti | ||
| 182 | 182 | dest_path = filepath.Clean(dest_path) | |
| 183 | 183 | ||
| 184 | 184 | mode := func(hdr int64) fs.FileMode { | |
| 185 | - | return fs.FileMode(hdr) & (fs.ModePerm | fs.ModeSetgid | fs.ModeSetuid | fs.ModeSticky) | |
| 185 | + | return fs.FileMode(hdr) & fs.ModePerm | |
| 186 | 186 | } | |
| 187 | 187 | ||
| 188 | 188 | set_metadata := func(chmod func(mode fs.FileMode) error, hdr_mode int64) (err error) { | |
| ··· | @@ -250,6 +250,12 @@ func ExtractAllFromTar(tr *tar.Reader, dest_path string, optss ...TarExtractOpti | ||
| 250 | 250 | if !filepath.IsAbs(link_target) { | |
| 251 | 251 | link_target = filepath.Join(filepath.Dir(dest), link_target) | |
| 252 | 252 | } | |
| 253 | + | if link_target, err = EvalSymlinksThatExist(link_target); err != nil { | |
| 254 | + | return | |
| 255 | + | } | |
| 256 | + | if !strings.HasPrefix(link_target, needed_prefix) { | |
| 257 | + | continue | |
| 258 | + | } | |
| 253 | 259 | if err = os.Link(link_target, dest); err != nil { | |
| 254 | 260 | return | |
| 255 | 261 | } | |
Conversation (3)
On Fri, Apr 03, 2026 at 08:12:37AM -0700, Z3rco wrote:
Summary
Thanks for the review.
Found and fixed several security issues across the C, Python, and Go code during a source audit. All changes are minimal and surgical — no refactoring, no behavior changes beyond the fixes themselves.
Timing-safe comparisons
- crypto.c —
Secret.__eq__usedmemcmp(not constant-time) and compared onlymin(len_a, len_b)bytes, so secrets of different lengths with a shared prefix compared equal. Switched toCRYPTO_memcmpwith a length-equality check first.- remote_control.py — Remote control passwords were looked up via
dict.get(), which leaks timing through Python's hash+compare. Replaced with a constant-time linear scan usinghmac.compare_digest.
This breaks type checking.
- file_transmission.py — Bypass token comparison in
check_bypassused==for both thekitty-1andsha256protocols. Switched tohmac.compare_digest.Memory safety (C)
- child-monitor.c —
write_to_peerhad an inverted condition (n > usedinstead ofn < used). Sincesend()never returns more thanused, thememmovenever fired, so partial writes resent stale data from the buffer head.
This is a bug, sure. Don't see how it's a security issue.
- ibus_glfw.c —
IBUS_ADDRESSenv var copied viamemcpywithout a null terminator. Whenstrlen(addr) >= PATH_MAX, the static buffer was returned unterminated.
Again dont see how this is a security issue. If the attacker can control the env vars the kitty process sees, its game over in a dozen different ways already.
- x11_window.c — Two
realloccalls (clipboard + DnD chunked writer) didn't check forNULL, leading to a use-after-free of the old pointer and amemcpyintoNULL + offset.
Again a bug, but a security issue? OOM will almost certainly end up terminating the process long before this could be exploited.
- dnd.c —
accepted_mimesbuffer had no size cap (unlikeregistered_mimeswhich caps at 1MB) and thereallocpattern leaked the old pointer on failure. Added the same 1MB cap and safe realloc.
Failure is OOM, leak of old memory doesnt really matter there as the process will die. As for no cap, yes but that's a potential DoS at best.
- png-reader.c —
rowbytes * heightwas computed aspng_uint_32arithmetic, which can overflow on 32-bit before assigning tosize_t. Added explicit casts.
The expression was sizeof(png_byte) * rowbytes * d->height and sizeof() evaluates to size_t.
Secrets hygiene
- disk-cache.c —
CacheValuecontains a 64-byte encryption key that was left in memory afterfree(). Addedexplicit_bzerobefore freeing.
explicit_bzero() is not portable. You need to use memset_s on macOS, for instance.
Tar extraction hardening
- tar.go — Hardlink targets were not validated against the destination prefix the way
destand symlinks were, allowing a malicious tar to hardlink outside the extraction directory then overwrite the target via a later entry. Also stripped setuid/setgid/sticky bits from extracted file modes.
Those bits are preserved by design, as should be obvious by the fact that they are explicitly enumerated. Also, ExtractAllFromTar is only used with tar files kitty itself creates, so not potentially malicious ones. That said, no harm in restricting hard links to be within the dest path.
Fair points. I used "security" loosely. These are correctness/robustness bugs, not exploitable vulnerabilities. The intent was to fix spots where the code didn't behave as written, not to claim active threat scenarios.
Yeah no worries, as I said thanks for the review. I am always happy to harden kitty code.