commit 5cfdd4d8b9016de84c0aa9fff7f2073b99f90f47
parent 9789a3b854d7f670dd231bdffe1bce0098509539
Author: zeertzjq <zeertzjq@outlook.com>
Date: Wed, 16 Jul 2025 09:08:57 +0800
vim-patch:9.1.1551: [security]: path traversal issue in zip.vim (#34951)
Problem: [security]: path traversal issue in zip.vim (@ax)
Solution: drop leading ../ on write of zipfiles, don't forcefully
overwrite existing files
A zip plugin which contains filenames with leading '../' may cause
confusion as to where the content will be extracted. Let's drop such
things and make sure we use a relative filename instead and don't
forcefully overwrite temporary files. Also, warn the user of such
things.
related: vim/vim#17733
https://github.com/vim/vim/commit/586294a04179d855c3d1d4ee5ea83931963680b8
vim-patch:e1044fb: runtime(zip): raise minimum Vim version to v9.0
vim-patch:e2d9b0d: runtime(zip): zip plugin does not work with Vim 9.0
Co-authored-by: Christian Brabandt <cb@256bit.org>
Diffstat:
4 files changed, 98 insertions(+), 60 deletions(-)
diff --git a/runtime/autoload/zip.vim b/runtime/autoload/zip.vim
@@ -15,6 +15,7 @@
" 2024 Aug 18 by Vim Project: correctly handle special globbing chars
" 2024 Aug 21 by Vim Project: simplify condition to detect MS-Windows
" 2025 Mar 11 by Vim Project: handle filenames with leading '-' correctly
+" 2025 Jul 12 by Vim Project: drop ../ on write to prevent path traversal attacks
" License: Vim License (see vim's :help license)
" Copyright: Copyright (C) 2005-2019 Charles E. Campbell {{{1
" Permission is hereby granted to use and distribute this code,
@@ -71,8 +72,9 @@ fun! s:Mess(group, msg)
echohl Normal
endfun
-if v:version < 702
- call s:Mess('WarningMsg', "***warning*** this version of zip needs vim 7.2 or later")
+if !has('nvim-0.10') && v:version < 901
+ " required for defer
+ call s:Mess('WarningMsg', "***warning*** this version of zip needs vim 9.1 or later")
finish
endif
" sanity checks
@@ -235,59 +237,62 @@ endfun
" zip#Write: {{{2
fun! zip#Write(fname)
let dict = s:SetSaneOpts()
+ let need_rename = 0
defer s:RestoreOpts(dict)
" sanity checks
if !executable(substitute(g:zip_zipcmd,'\s\+.*$','',''))
- call s:Mess('Error', "***error*** (zip#Write) sorry, your system doesn't appear to have the ".g:zip_zipcmd." program")
- return
- endif
- if !exists("*mkdir")
- call s:Mess('Error', "***error*** (zip#Write) sorry, mkdir() doesn't work on your system")
- return
+ call s:Mess('Error', "***error*** (zip#Write) sorry, your system doesn't appear to have the ".g:zip_zipcmd." program")
+ return
endif
let curdir= getcwd()
let tmpdir= tempname()
if tmpdir =~ '\.'
- let tmpdir= substitute(tmpdir,'\.[^.]*$','','e')
+ let tmpdir= substitute(tmpdir,'\.[^.]*$','','e')
endif
call mkdir(tmpdir,"p")
" attempt to change to the indicated directory
if s:ChgDir(tmpdir,s:ERROR,"(zip#Write) cannot cd to temporary directory")
- return
+ return
endif
" place temporary files under .../_ZIPVIM_/
if isdirectory("_ZIPVIM_")
- call delete("_ZIPVIM_", "rf")
+ call delete("_ZIPVIM_", "rf")
endif
call mkdir("_ZIPVIM_")
cd _ZIPVIM_
if has("unix")
- let zipfile = substitute(a:fname,'zipfile://\(.\{-}\)::[^\\].*$','\1','')
- let fname = substitute(a:fname,'zipfile://.\{-}::\([^\\].*\)$','\1','')
+ let zipfile = substitute(a:fname,'zipfile://\(.\{-}\)::[^\\].*$','\1','')
+ let fname = substitute(a:fname,'zipfile://.\{-}::\([^\\].*\)$','\1','')
else
- let zipfile = substitute(a:fname,'^.\{-}zipfile://\(.\{-}\)::[^\\].*$','\1','')
- let fname = substitute(a:fname,'^.\{-}zipfile://.\{-}::\([^\\].*\)$','\1','')
+ let zipfile = substitute(a:fname,'^.\{-}zipfile://\(.\{-}\)::[^\\].*$','\1','')
+ let fname = substitute(a:fname,'^.\{-}zipfile://.\{-}::\([^\\].*\)$','\1','')
+ endif
+ if fname =~ '^[.]\{1,2}/'
+ call system(g:zip_zipcmd." -d ".s:Escape(fnamemodify(zipfile,":p"),0)." ".s:Escape(fname,0))
+ let fname = fname->substitute('^\([.]\{1,2}/\)\+', '', 'g')
+ let need_rename = 1
endif
if fname =~ '/'
- let dirpath = substitute(fname,'/[^/]\+$','','e')
- if has("win32unix") && executable("cygpath")
+ let dirpath = substitute(fname,'/[^/]\+$','','e')
+ if has("win32unix") && executable("cygpath")
let dirpath = substitute(system("cygpath ".s:Escape(dirpath,0)),'\n','','e')
- endif
- call mkdir(dirpath,"p")
+ endif
+ call mkdir(dirpath,"p")
endif
if zipfile !~ '/'
- let zipfile= curdir.'/'.zipfile
+ let zipfile= curdir.'/'.zipfile
endif
- exe "w! ".fnameescape(fname)
+ " don't overwrite files forcefully
+ exe "w ".fnameescape(fname)
if has("win32unix") && executable("cygpath")
- let zipfile = substitute(system("cygpath ".s:Escape(zipfile,0)),'\n','','e')
+ let zipfile = substitute(system("cygpath ".s:Escape(zipfile,0)),'\n','','e')
endif
if (has("win32") || has("win95") || has("win64") || has("win16")) && &shell !~? 'sh$'
@@ -296,21 +301,24 @@ fun! zip#Write(fname)
call system(g:zip_zipcmd." -u ".s:Escape(fnamemodify(zipfile,":p"),0)." ".s:Escape(fname,0))
if v:shell_error != 0
- call s:Mess('Error', "***error*** (zip#Write) sorry, unable to update ".zipfile." with ".fname)
+ call s:Mess('Error', "***error*** (zip#Write) sorry, unable to update ".zipfile." with ".fname)
elseif s:zipfile_{winnr()} =~ '^\a\+://'
- " support writing zipfiles across a network
- let netzipfile= s:zipfile_{winnr()}
- 1split|enew
- let binkeep= &binary
- let eikeep = &ei
- set binary ei=all
- exe "noswapfile e! ".fnameescape(zipfile)
- call netrw#NetWrite(netzipfile)
- let &ei = eikeep
- let &binary = binkeep
- q!
- unlet s:zipfile_{winnr()}
+ " support writing zipfiles across a network
+ let netzipfile= s:zipfile_{winnr()}
+ 1split|enew
+ let binkeep= &binary
+ let eikeep = &ei
+ set binary ei=all
+ exe "noswapfile e! ".fnameescape(zipfile)
+ call netrw#NetWrite(netzipfile)
+ let &ei = eikeep
+ let &binary = binkeep
+ q!
+ unlet s:zipfile_{winnr()}
+ elseif need_rename
+ exe $"sil keepalt file {fnameescape($"zipfile://{zipfile}::{fname}")}"
+ call s:Mess('Warning', "***error*** (zip#Browse) Path Traversal Attack detected, dropping relative path")
endif
" cleanup and restore current directory
@@ -319,7 +327,6 @@ fun! zip#Write(fname)
call s:ChgDir(curdir,s:WARNING,"(zip#Write) unable to return to ".curdir."!")
call delete(tmpdir, "rf")
setlocal nomod
-
endfun
" ---------------------------------------------------------------------
@@ -332,15 +339,18 @@ fun! zip#Extract()
" sanity check
if fname =~ '^"'
- return
+ return
endif
if fname =~ '/$'
- call s:Mess('Error', "***error*** (zip#Extract) Please specify a file, not a directory")
- return
+ call s:Mess('Error', "***error*** (zip#Extract) Please specify a file, not a directory")
+ return
+ elseif fname =~ '^[.]\?[.]/'
+ call s:Mess('Error', "***error*** (zip#Browse) Path Traversal Attack detected, not extracting!")
+ return
endif
if filereadable(fname)
- call s:Mess('Error', "***error*** (zip#Extract) <" .. fname .."> already exists in directory, not overwriting!")
- return
+ call s:Mess('Error', "***error*** (zip#Extract) <" .. fname .."> already exists in directory, not overwriting!")
+ return
endif
let target = fname->substitute('\[', '[[]', 'g')
" unzip 6.0 does not support -- to denote end-of-arguments
@@ -362,13 +372,12 @@ fun! zip#Extract()
" extract the file mentioned under the cursor
call system($"{g:zip_extractcmd} -o {shellescape(b:zipfile)} {target}")
if v:shell_error != 0
- call s:Mess('Error', "***error*** ".g:zip_extractcmd." ".b:zipfile." ".fname.": failed!")
+ call s:Mess('Error', "***error*** ".g:zip_extractcmd." ".b:zipfile." ".fname.": failed!")
elseif !filereadable(fname)
- call s:Mess('Error', "***error*** attempted to extract ".fname." but it doesn't appear to be present!")
+ call s:Mess('Error', "***error*** attempted to extract ".fname." but it doesn't appear to be present!")
else
- echomsg "***note*** successfully extracted ".fname
+ echomsg "***note*** successfully extracted ".fname
endif
-
endfun
" ---------------------------------------------------------------------
diff --git a/runtime/doc/pi_zip.txt b/runtime/doc/pi_zip.txt
@@ -111,6 +111,18 @@ Copyright: Copyright (C) 2005-2015 Charles E Campbell *zip-copyright*
==============================================================================
4. History *zip-history* {{{1
+ unreleased:
+ Jul 12, 2025 * drop ../ on write to prevent path traversal attacks
+ Mar 11, 2025 * handle filenames with leading '-' correctly
+ Aug 21, 2024 * simplify condition to detect MS-Windows
+ Aug 18, 2024 * correctly handle special globbing chars
+ Aug 05, 2024 * clean-up and make it work with shellslash on Windows
+ Aug 05, 2024 * workaround for the FreeBSD's unzip
+ Aug 04, 2024 * escape '[' in name of file to be extracted
+ Jul 30, 2024 * fix opening remote zipfile
+ Jul 24, 2024 * use delete() function
+ Jul 23, 2024 * fix 'x' command
+ Jun 16, 2024 * handle whitespace on Windows properly (#14998)
v33 Dec 07, 2021 * `*.xlam` mentioned twice in zipPlugin
v32 Oct 22, 2021 * to avoid an issue with a vim 8.2 patch, zipfile: has
been changed to zipfile:// . This often shows up
diff --git a/test/old/testdir/samples/evil.zip b/test/old/testdir/samples/evil.zip
Binary files differ.
diff --git a/test/old/testdir/test_plugin_zip.vim b/test/old/testdir/test_plugin_zip.vim
@@ -9,13 +9,14 @@ endif
runtime plugin/zipPlugin.vim
-func Test_zip_basic()
-
- "## get our zip file
- if !filecopy("samples/test.zip", "X.zip")
- call assert_report("Can't copy samples/test.zip")
- return
+func s:CopyZipFile(source)
+ if !filecopy($"samples/{a:source}", "X.zip")
+ call assert_report($"Can't copy samples/{a:source}.zip")
endif
+endfunc
+
+func Test_zip_basic()
+ call s:CopyZipFile("test.zip")
defer delete("X.zip")
e X.zip
@@ -142,11 +143,7 @@ func Test_zip_glob_fname()
CheckNotMSWindows
" does not work on Windows, why?
- "## copy sample zip file
- if !filecopy("samples/testa.zip", "X.zip")
- call assert_report("Can't copy samples/testa.zip")
- return
- endif
+ call s:CopyZipFile("testa.zip")
defer delete("X.zip")
defer delete('zipglob', 'rf')
@@ -240,10 +237,7 @@ func Test_zip_fname_leading_hyphen()
CheckNotMSWindows
"## copy sample zip file
- if !filecopy("samples/poc.zip", "X.zip")
- call assert_report("Can't copy samples/poc.zip")
- return
- endif
+ call s:CopyZipFile("poc.zip")
defer delete("X.zip")
defer delete('-d', 'rf')
defer delete('/tmp/pwned', 'rf')
@@ -258,3 +252,26 @@ func Test_zip_fname_leading_hyphen()
call assert_false(filereadable('/tmp/pwned'))
bw
endfunc
+
+func Test_zip_fname_evil_path()
+ CheckNotMSWindows
+ " needed for writing the zip file
+ CheckExecutable zip
+
+ call s:CopyZipFile("evil.zip")
+ defer delete("X.zip")
+ e X.zip
+
+ :1
+ let fname = 'pwn'
+ call search('\V' .. fname)
+ normal x
+ call assert_false(filereadable('/etc/ax-pwn'))
+ let mess = execute(':mess')
+ call assert_match('Path Traversal Attack', mess)
+
+ exe ":normal \<cr>"
+ :w
+ call assert_match('zipfile://.*::etc/ax-pwn', @%)
+ bw
+endfunc