Skip to content

Commit 2478b62

Browse files
committed
fix: encode PowerShell scripts across the Windows shell boundary
Preserve UTF-8 paths and exit status through GopherLua and cmd.exe without nested command quotes. Fixes version-fox/vfox#685
1 parent b10bd02 commit 2478b62

2 files changed

Lines changed: 77 additions & 9 deletions

File tree

‎lib/windows_command.lua‎

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,40 @@
11
local command = {}
22

3-
-- os.execute/io.popen pass through cmd.exe before PowerShell. Do not expose
4-
-- percent expansion, delayed expansion, or quotes to that outer parser.
3+
local alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
4+
local function base64(value)
5+
local result = {}
6+
for i = 1, #value, 3 do
7+
local a, b, c = value:byte(i, i + 2)
8+
local number = a * 65536 + (b or 0) * 256 + (c or 0)
9+
local first = math.floor(number / 262144) + 1
10+
local second = math.floor(number / 4096) % 64 + 1
11+
local third = math.floor(number / 64) % 64 + 1
12+
local fourth = number % 64 + 1
13+
result[#result + 1] = alphabet:sub(first, first) .. alphabet:sub(second, second) ..
14+
(b and alphabet:sub(third, third) or '=') ..
15+
(c and alphabet:sub(fourth, fourth) or '=')
16+
end
17+
return table.concat(result)
18+
end
19+
20+
-- Encode UTF-8 values separately so even non-ASCII paths produce an ASCII
21+
-- PowerShell script, without exposing arguments to either shell's parser.
522
local function literal(value)
623
if value:find('[\r\n%z]') then
724
error('Windows command argument contains a control character')
825
end
9-
value = value:gsub("'", "''")
10-
value = value:gsub('[%%!\"]', function(char)
11-
return "' + [char]" .. string.byte(char) .. " + '"
12-
end)
13-
return "('" .. value .. "')"
26+
return "([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" .. base64(value) .. "')))"
1427
end
1528

1629
local function powershell(script)
17-
return 'powershell -NoProfile -NonInteractive -Command "' ..
18-
"$ErrorActionPreference = 'Stop'; " .. script .. '"'
30+
script = "$ErrorActionPreference = 'Stop'; " .. script
31+
-- GopherLua launches cmd.exe /c, which reinterprets a quoted -Command.
32+
-- -EncodedCommand takes UTF-16LE base64 with no quotes or metacharacters.
33+
local utf16 = script:gsub('.', function(char)
34+
assert(char:byte() < 128, 'PowerShell script must contain only ASCII')
35+
return char .. '\0'
36+
end)
37+
return 'powershell -NoProfile -NonInteractive -EncodedCommand ' .. base64(utf16)
1938
end
2039

2140
function command.native(program, args)

‎tests/windows_install_test.lua‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,47 @@ package.preload.http = function() return {download_file = function() return nil
1111
package.preload.html = function() return {} end
1212
package.preload.json = function() return {} end
1313
local oldExecute, oldPopen, oldOpen, oldClose, oldRemove = os.execute, io.popen, io.open, io.close, os.remove
14+
15+
-- Decode the command independently so the test inspects what PowerShell will
16+
-- execute, rather than only checking a command string prefix.
17+
local alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
18+
local function decodeBase64(value)
19+
local result, accumulator, bits = {}, 0, 0
20+
for char in value:gmatch('.') do
21+
if char == '=' then break end
22+
local index = assert(alphabet:find(char, 1, true), 'invalid base64') - 1
23+
accumulator = accumulator * 64 + index
24+
bits = bits + 6
25+
if bits >= 8 then
26+
bits = bits - 8
27+
local scale = 2 ^ bits
28+
result[#result + 1] = string.char(math.floor(accumulator / scale))
29+
accumulator = accumulator % scale
30+
end
31+
end
32+
return table.concat(result)
33+
end
34+
local function decodeCommand(value)
35+
local payload = assert(value:match('^powershell %-NoProfile %-NonInteractive %-EncodedCommand ([A-Za-z0-9+/=]+)$'),
36+
'PowerShell command must contain only an unquoted base64 payload: ' .. value)
37+
local utf16 = decodeBase64(payload)
38+
assert(#utf16 % 2 == 0, 'incomplete UTF-16LE code unit')
39+
local bytes = {}
40+
for i = 1, #utf16, 2 do
41+
assert(utf16:byte(i) < 128 and utf16:byte(i + 1) == 0, 'script must use ASCII UTF-16LE')
42+
bytes[#bytes + 1] = utf16:sub(i, i)
43+
end
44+
local script = table.concat(bytes)
45+
assert(script:find("$ErrorActionPreference = 'Stop'; ", 1, true) == 1, 'error preference must execute')
46+
return script
47+
end
48+
1449
os.execute = function(command)
1550
state.commands[#state.commands + 1] = command
1651
-- Native paths must never be expanded as cmd variables or tokenized on spaces.
1752
assert(command:find('powershell ', 1, true), 'unquoted Windows command: ' .. command)
1853
assert(not command:find('%%') and not command:find('!'), 'cmd expansion in command: ' .. command)
54+
decodeCommand(command)
1955
return #state.commands == state.failOn and 1 or 0
2056
end
2157
io.popen = function(command)
@@ -74,6 +110,19 @@ for _, failure in ipairs({
74110
assert(#state.commands == 2, failure.name .. ': no MSI or pip may run before enumeration succeeds')
75111
end
76112
local windows = require('windows_command')
113+
for _, value in ipairs({'', 'f', 'fo', 'foo', 'foob', 'fooba', 'foobar', "C:\\用户\\Mechael's & %USERNAME% !\\", 'quotes"stay data'}) do
114+
local script = decodeCommand(windows.native('program.exe', {value}))
115+
local arguments = {}
116+
for encoded in script:gmatch("FromBase64String%('([A-Za-z0-9+/=]*)'%)") do
117+
arguments[#arguments + 1] = decodeBase64(encoded)
118+
end
119+
assert(#arguments == 2 and arguments[1] == 'program.exe' and arguments[2] == value,
120+
'native argument changed after payload decoding')
121+
assert(script:find('; exit $LASTEXITCODE', 1, true), 'native exit status must propagate')
122+
end
123+
local msi = decodeCommand(windows.msi('C:\\MSI files\\core.msi', path))
124+
assert(msi:find('Start-Process -FilePath msiexec.exe -Wait -PassThru', 1, true))
125+
assert(msi:find('; exit $process.ExitCode', 1, true))
77126
assert(not pcall(windows.msi, 'C:\\bad"path\\python.msi', path), 'invalid Windows quote must fail')
78127
assert(not pcall(windows.native, 'bad\nprogram', {}), 'control characters must fail')
79128
os.execute, io.popen, io.open, io.close, os.remove = oldExecute, oldPopen, oldOpen, oldClose, oldRemove

0 commit comments

Comments
 (0)