← back to research

Vim security research: memory-safety and command-injection findings

Cipher's ongoing security research into Vim: memory-safety bugs in spell-file, soundfolding, text-property, and libsodium-decryption handling, plus command-injection and code-execution flaws in netrw and C omni-completion, all reported upstream and fixed.


Vim ships on nearly every Unix system in the world. Code that mature and that widely read is exactly where bugs are easiest to assume away: thousands of people have looked at it, so surely the bounds are sound and the escaping is complete. As part of our open-source research, we have been auditing Vim. They were not.

This post collects what we have reported, and we add to it as advisories go public. Each finding below was reported to the Vim maintainers and fixed upstream; CVEs are assigned where noted.

Out-of-bounds write in spell-file word count

Vim’s spell checker reads compiled, binary spell files. When spell checking is enabled and a user asks for suggestions on a misspelled word, Vim walks the spell file’s word trie to enumerate candidates.

tree_count_words() in src/spellfile.c walks that trie with a depth counter that indexes three fixed-size stack arrays, each holding MAXWLEN (254) elements. The counter is bounded by the structure of the trie being walked, but it is never checked against the size of the arrays it indexes. A spell file whose trie nests deeper than MAXWLEN drives the counter past the end of those arrays, and the per-level write lands outside them: an out-of-bounds write that corrupts the function’s stack frame and crashes the editor.

It is a denial of service, not code execution. The realistic delivery is a repository or archive that ships a malicious spell-file sidecar a victim then spell-checks against. Fixed in 9.2.0653 by bounding the depth counter. Advisory: GHSA-wgh4-64f7-q3jq, CVE-2026-55693, CWE-787, Moderate.

Out-of-bounds write in spell-file prefix dump

The same class, in a different routine. dump_prefixes() in spell.c traverses a prefix trie without depth validation, so a crafted spell file drives writes past the same kind of fixed-size stack arrays. The outcome is again a stack out-of-bounds write that crashes Vim when spell suggestion runs against the malicious file.

That two independent spell-file traversals shared the same missing-bound shape is the useful signal: the invariant “the trie is no deeper than the buffers sized for it” was assumed in more than one place and enforced in none. Fixed in 9.2.0662. Advisory: GHSA-qm9w-fmpj-879h, CVE-2026-55892, CWE-787, Moderate (CVSS 5.5).

Command injection in netrw file deletion

This one is not a crash. netrw, Vim’s built-in file browser, is auto-loaded when you open a directory. Its local delete handler s:NetrwLocalRmFile() builds the delete target from the attacker-controlled filename with backslash-only escaping, then runs it through :execute:

let rmfile = ...escape(a:fname, '\')...        " escapes the backslash char only
execute printf('silent! bwipeout %s', rmfile)  " :execute of attacker-controlled text

On Unix, | is both a legal filename character and the Ex command separator. So a file named x|call system('cmd')|y turns a single delete into three Ex commands, and the injected :call system(...) runs an arbitrary shell command. The chain fires from browsing a directory and pressing D, then confirming with y, on the crafted file.

The tell is that sibling sinks in the same file already use Vim’s correct escaper, fnameescape(). This one carried backslash-only escaping, an incomplete fix left over from an earlier functional change, which is why it survived the round of netrw URL and filename fixes that landed elsewhere. Delivery is realistic: clone a hostile repository or extract an archive, browse it in Vim, press D on a file.

The impact is arbitrary command execution, materially more serious than the spell-file crashes. Fixed in 9.2.0663 by escaping for the Ex context at the sink:

execute printf('silent! bwipeout %s', fnameescape(rmfile))

Advisory: GHSA-vhh8-v6wx-hjjh, CVE-2026-55895, CWE-78.

Out-of-bounds read in text-property count

A fourth, in a different subsystem again. Vim attaches text properties to lines, and get_text_props() in src/textprop.c reads a 16-bit count of how many a line carries. It checks that there is room for one property, then trusts the count and reads them all, without checking that many actually fit behind it. A crafted undo file that declares an inflated count makes the function read far past the line buffer: an out-of-bounds read that both leaks adjacent memory and can crash the editor.

Reaching it takes a few steps in order (undo files enabled, open the file, undo, then display or inspect the properties), and the delivery is the same shape as the others: a malicious source-and-undo file pair shipped in a repository or archive. This one was co-discovered independently, so we share credit. Fixed in 9.2.0670. Advisory: GHSA-f36c-2qcp-7gpw, CVE-2026-57451, CWE-125, Moderate (CVSS 5.3).

Out-of-bounds read in libsodium-encrypted file decryption

A fifth, in the encryption path. Vim can read and write files encrypted with libsodium, the VimCrypt~04! and VimCrypt~05! formats, when it is built with the +sodium feature. crypt_sodium_buffer_decode() in src/crypt.c decodes such a buffer by subtracting a fixed header length from the body length to size the decryption.

When the body is shorter than that header, under 24 bytes, the subtraction runs on an unsigned length and underflows, wrapping to a huge value. The decryption call then reads far past the end of the input buffer. It is an out-of-bounds read driven by an integer underflow: the code assumes the body is at least as long as the header it strips, and never checks.

The impact is a reliable crash, a denial of service, with no information disclosure or data modification. It triggers when a user opens a crafted, truncated libsodium-encrypted file in a Vim built with +sodium. Fixed in 9.2.0671 by validating the body length before the subtraction. Advisory: GHSA-c4j9-wr9j-4486, CVE-2026-57452, CWE-191 and CWE-125, Moderate (CVSS 5.5).

Out-of-bounds write in SOFO soundfolding

A sixth, back in the spell subsystem and in a different routine again. Soundfolding maps a word to a phonetic key so the suggester can find words that sound alike. With a SOFO-style spell language, spell_soundfold_sofo() in src/spell.c builds that key into a fixed MAXWLEN-sized buffer, advancing an output index ri as it writes.

The multibyte branch guards that write; the single-byte branch does not:

// multibyte branch: bounded
if (ri + MB_MAXBYTES > MAXWLEN)
    break;
// single-byte branch: advances ri with no upper bound,
// terminating only on the input NUL

Soundfold a long enough word, in a non-multibyte 8-bit encoding, against a loaded SOFO language, and the single-byte branch runs ri off the end of the buffer: a stack out-of-bounds write that corrupts the function’s frame and crashes the editor.

The shape is by now familiar, one branch carrying the bound while its sibling carries none, the same asymmetry the prefix-dump and word-count walks showed inside the spell files themselves. It is a denial of service, not code execution, reached when suggestions run for a misspelling under those encoding and language conditions. Fixed in 9.2.0698 by bounding the single-byte branch. Advisory: GHSA-q8mh-6qm3-25g4, CVE-2026-57455, CWE-787, Moderate.

Arbitrary code execution in C omni-completion

The most serious of the set, and a sibling of the netrw injection above: this one is not a crash but direct code execution. Vim’s C omni-completion (the CTRL-X CTRL-O member completion offered after a struct’s .) resolves types by consulting the project’s tags file. To do so, runtime/autoload/ccomplete.vim takes the typeref: / typename: field from the matching tag entry and interpolates it, without escaping, into a :vimgrep pattern that is then run through :execute.

:vimgrep uses the bar as a command separator, and the bar is a legal character in a tag field. So a tag whose type field carries a payload like

typeref:struct:x|call system('id > /tmp/pwned')|

closes the :vimgrep pattern and appends an arbitrary Ex command: the injected :call system(...) runs a shell command with the editing user’s full privileges. No memory corruption, no crash: direct, reliable code execution.

The delivery is what makes it matter. A tags file sits in the project directory and reads as data, not code, so it is rarely treated as hostile. Clone a repository that ships one, or unpack a source archive that carries it, open a C file, and invoke member completion (the ordinary motion of reading unfamiliar C), and the command runs. It is the same shape as the netrw bug above: attacker-controlled text reaching :execute through a sink whose escaping looked complete, while its siblings escape correctly. Fixed in 9.2.0735 by escaping the interpolated field. Advisory: GHSA-mf92-v4xw-j45x, CVE-2026-59858, CWE-94 and CWE-829, Moderate.

Why we look here

Five of these are memory-safety bugs hiding behind implicit bounds in mature C; two are injections hiding behind escaping that looked done. None is exotic. Each is an invariant the code assumes against input an attacker controls: a trie no deeper than its buffer, an output index that stays inside the buffer it fills, a count no larger than the data behind it, a length that cannot be shorter than the header stripped from it, a filename or a tag field with no Ex metacharacters. Reasoning about those gaps across a large, trusted codebase is what our open-source research focuses on.

The reach is also wide. Vim’s core is shared by Vim-derived editors and shipped by every major Linux distribution, so a single upstream fix propagates downstream, into editor and distribution updates, well beyond Vim itself.

This is part of a series of upstream findings we are reporting across widely deployed open-source and embedded software. We publish the interesting ones as each embargo lifts.