z3rco Algeria

Junior Security Researcher · Source Code Auditor

z3rco
Back to contributions

Fix timing, memory safety, and tar extraction vulnerabilities #9802

Merged z3rco z3rco merged security-hardening into master
1 commit 10 files changed +44 -19 Apr 3, 2026, 04:56 PM
View on GitHub

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.cSecret.__eq__ used memcmp (not constant-time) and compared only min(len_a, len_b) bytes, so secrets of different lengths with a shared prefix compared equal. Switched to CRYPTO_memcmp with 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 using hmac.compare_digest.
  • file_transmission.py — Bypass token comparison in check_bypass used == for both the kitty-1 and sha256 protocols. Switched to hmac.compare_digest.

Memory safety (C)

  • child-monitor.cwrite_to_peer had an inverted condition (n > used instead of n < used). Since send() never returns more than used, the memmove never fired, so partial writes resent stale data from the buffer head.
  • ibus_glfw.cIBUS_ADDRESS env var copied via memcpy without a null terminator. When strlen(addr) >= PATH_MAX, the static buffer was returned unterminated.
  • x11_window.c — Two realloc calls (clipboard + DnD chunked writer) didn't check for NULL, leading to a use-after-free of the old pointer and a memcpy into NULL + offset.
  • dnd.caccepted_mimes buffer had no size cap (unlike registered_mimes which caps at 1MB) and the realloc pattern leaked the old pointer on failure. Added the same 1MB cap and safe realloc.
  • png-reader.crowbytes * height was computed as png_uint_32 arithmetic, which can overflow on 32-bit before assigning to size_t. Added explicit casts.

Secrets hygiene

  • disk-cache.cCacheValue contains a 64-byte encryption key that was left in memory after free(). Added explicit_bzero before freeing.

Tar extraction hardening

  • tar.go — Hardlink targets were not validated against the destination prefix the way dest and 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_compile and ast.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)

kovidgoyal kovidgoyal commented Apr 3, 2026

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.cSecret.__eq__ used memcmp (not constant-time) and compared only min(len_a, len_b) bytes, so secrets of different lengths with a shared prefix compared equal. Switched to CRYPTO_memcmp with 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 using hmac.compare_digest.

This breaks type checking.

  • file_transmission.py — Bypass token comparison in check_bypass used == for both the kitty-1 and sha256 protocols. Switched to hmac.compare_digest.

Memory safety (C)

  • child-monitor.cwrite_to_peer had an inverted condition (n &gt; used instead of n &lt; used). Since send() never returns more than used, the memmove never 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.cIBUS_ADDRESS env var copied via memcpy without a null terminator. When strlen(addr) &gt;= 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 realloc calls (clipboard + DnD chunked writer) didn't check for NULL, leading to a use-after-free of the old pointer and a memcpy into NULL + offset.

Again a bug, but a security issue? OOM will almost certainly end up terminating the process long before this could be exploited.

  • dnd.caccepted_mimes buffer had no size cap (unlike registered_mimes which caps at 1MB) and the realloc pattern 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.crowbytes * height was computed as png_uint_32 arithmetic, which can overflow on 32-bit before assigning to size_t. Added explicit casts.

The expression was sizeof(png_byte) * rowbytes * d->height and sizeof() evaluates to size_t.

Secrets hygiene

  • disk-cache.cCacheValue contains a 64-byte encryption key that was left in memory after free(). Added explicit_bzero before 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 dest and 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.

z3rco z3rco commented Apr 5, 2026

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.

kovidgoyal kovidgoyal commented Apr 5, 2026

Yeah no worries, as I said thanks for the review. I am always happy to harden kitty code.