drop.ps1
|
#!/usr/bin/env pwsh <#PSScriptInfo .VERSION 1.0.3 .GUID bac66db3-4d59-4e53-9b56-0ae8a98179cc .AUTHOR Kevin Swiber .COPYRIGHT (c) 2026 Kevin Swiber. MIT License. .TAGS Windows Security Privileges Elevation UAC Token Administrator SSH .LICENSEURI https://github.com/kevinswiber/drop-ps1/blob/main/LICENSE .PROJECTURI https://github.com/kevinswiber/drop-ps1 .RELEASENOTES https://github.com/kevinswiber/drop-ps1/releases #> <# .SYNOPSIS Run a command on Windows without elevated privileges. .DESCRIPTION Drops elevated privileges (Administrators group made deny-only, Medium integrity) and runs a command, or an interactive shell when no command is given. A PowerShell port of busybox-w32's drop/pdrop applets. Useful over SSH, where an administrator account always gets an elevated session. .LINK https://github.com/kevinswiber/drop-ps1 #> # drop.ps1 - PowerShell port of busybox-w32's drop/pdrop applet. # # Usage: drop [COMMAND [ARG...] | [-s SHELL] -c CMD_STRING [ARG...]] # # Install: put this bin directory on PATH. PowerShell runs `drop` as drop.ps1 # directly; drop.cmd is a shim for cmd.exe and `drop` (no extension) is a # shim for Git bash / MSYS. The shebang lets Git bash run ./drop.ps1 too. # # Drops elevated privileges (Administrators group -> deny-only, Medium # integrity) and runs COMMAND. With no command, runs an interactive shell: # the one you invoked it from (pwsh, powershell, cmd, bash, ...), or the one # named by -s. Works from Windows PowerShell 5.1 and pwsh 7. # # Note: the child inherits this process's real std handles, so when run # interactively its output goes straight to the console, not through the # PowerShell pipeline. To capture it, invoke as `pwsh -File drop.ps1 ...`. # # Mirrors miscutils/drop.c: SaferCreateLevel(NORMALUSER) -> # SaferComputeTokenFromLevel -> SetTokenInformation(Medium) -> # CreateProcessAsUser with inherited std handles -> wait -> exit code. $ErrorActionPreference = 'Stop' # --help and --version answer before anything platform-specific runs. # (`drop -?` never gets here: PowerShell answers it with the comment-based help.) if ($args.Count -eq 1 -and $args[0] -in '-h', '--help') { @' Usage: drop [COMMAND [ARG...] | [-s SHELL] -c CMD_STRING [ARG...]] Drop elevated privileges (Administrators deny-only, Medium integrity) and run COMMAND. With no command, run an interactive shell: the one drop was invoked from, or SHELL. -c CMD_STRING run CMD_STRING in the shell (passed as -c, or /c for cmd.exe) -s SHELL use SHELL (name or path) instead of the invoking shell -h, --help show this help and exit --version show the version and exit Exit status is the child's, or 127 if COMMAND is not found, 126 if it could not be started. Set DROP_DEBUG=1 to see how the shell was chosen. https://github.com/kevinswiber/drop-ps1 '@ exit 0 } if ($args.Count -eq 1 -and $args[0] -eq '--version') { # The version lives in the PSScriptInfo header at the top of this file. $m = [regex]::Match((Get-Content -LiteralPath $PSCommandPath -Raw), '(?m)^\.VERSION\s+(\S+)') "drop $(if ($m.Success) { $m.Groups[1].Value } else { 'unknown' })" exit 0 } # Everything below is Win32 (Safer tokens, integrity levels, CreateProcessAsUser). # $IsWindows does not exist on Windows PowerShell 5.1, so check the platform. if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { $os = if ($PSVersionTable.OS) { $PSVersionTable.OS } else { [Environment]::OSVersion.VersionString } [Console]::Error.WriteLine("drop: Windows only. Privilege dropping uses the Win32 token APIs; this is $os.") exit 1 } # Add-Type'd types live for the whole session and can't be replaced, so the # class name carries a hash of its source: editing the C# below yields a fresh # type instead of silently reusing a stale one from an earlier run. $nativeSource = @' using System; using System.Runtime.InteropServices; using System.Text; public static class DropNative { const uint SAFER_SCOPEID_USER = 2; const uint SAFER_LEVELID_NORMALUSER = 0x20000; const uint SAFER_LEVEL_OPEN = 1; const int TokenIntegrityLevel = 25; const uint SE_GROUP_INTEGRITY = 0x20; const uint STARTF_USESTDHANDLES = 0x100; const int STD_INPUT_HANDLE = -10; const int STD_OUTPUT_HANDLE = -11; const int STD_ERROR_HANDLE = -12; const uint INFINITE = 0xFFFFFFFF; [StructLayout(LayoutKind.Sequential)] struct SID_AND_ATTRIBUTES { public IntPtr Sid; public uint Attributes; } [StructLayout(LayoutKind.Sequential)] struct TOKEN_MANDATORY_LABEL { public SID_AND_ATTRIBUTES Label; } [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] struct STARTUPINFO { public int cb; public string lpReserved, lpDesktop, lpTitle; public int dwX, dwY, dwXSize, dwYSize, dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags; public short wShowWindow, cbReserved2; public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError; } [StructLayout(LayoutKind.Sequential)] struct PROCESS_INFORMATION { public IntPtr hProcess, hThread; public uint dwProcessId, dwThreadId; } delegate bool ConsoleCtrlDelegate(uint ctrlType); [DllImport("advapi32.dll", SetLastError = true)] static extern bool SaferCreateLevel(uint scope, uint level, uint flags, out IntPtr handle, IntPtr reserved); [DllImport("advapi32.dll", SetLastError = true)] static extern bool SaferCloseLevel(IntPtr handle); [DllImport("advapi32.dll", SetLastError = true)] static extern bool SaferComputeTokenFromLevel(IntPtr level, IntPtr inToken, out IntPtr outToken, uint flags, IntPtr reserved); [DllImport("advapi32.dll", SetLastError = true)] static extern bool SetTokenInformation(IntPtr token, int cls, ref TOKEN_MANDATORY_LABEL info, uint len); [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] static extern bool CreateProcessAsUserW(IntPtr token, string app, StringBuilder cmdLine, IntPtr procAttrs, IntPtr threadAttrs, bool inherit, uint flags, IntPtr env, string cwd, ref STARTUPINFO si, out PROCESS_INFORMATION pi); [DllImport("kernel32.dll", SetLastError = true)] static extern IntPtr GetStdHandle(int which); [DllImport("kernel32.dll", SetLastError = true)] static extern bool CloseHandle(IntPtr h); [DllImport("kernel32.dll", SetLastError = true)] static extern uint WaitForSingleObject(IntPtr h, uint ms); [DllImport("kernel32.dll", SetLastError = true)] static extern bool GetExitCodeProcess(IntPtr h, out uint code); [DllImport("kernel32.dll", SetLastError = true)] static extern bool SetConsoleCtrlHandler(ConsoleCtrlDelegate handler, bool add); static void Fail(string what) { int err = Marshal.GetLastWin32Error(); throw new System.ComponentModel.Win32Exception(err, what + " failed (error " + err + ")"); } // Launch exe with cmdLine under a de-elevated, Medium-integrity token. // Returns the child's exit code. public static int Run(string exe, string cmdLine) { IntPtr safer = IntPtr.Zero, token = IntPtr.Zero; PROCESS_INFORMATION pi = new PROCESS_INFORMATION(); // S-1-16-8192 (Medium mandatory level), same bytes as drop.c byte[] medium = { 0x01,0x01,0,0, 0,0,0,0x10, 0,0x20,0,0 }; GCHandle pin = GCHandle.Alloc(medium, GCHandleType.Pinned); // Parent ignores Ctrl+C while waiting; the child shares the console // and decides for itself what to do with it. ConsoleCtrlDelegate ignore = delegate(uint t) { return true; }; try { if (!SaferCreateLevel(SAFER_SCOPEID_USER, SAFER_LEVELID_NORMALUSER, SAFER_LEVEL_OPEN, out safer, IntPtr.Zero)) Fail("SaferCreateLevel"); if (!SaferComputeTokenFromLevel(safer, IntPtr.Zero, out token, 0, IntPtr.Zero)) Fail("SaferComputeTokenFromLevel"); TOKEN_MANDATORY_LABEL til = new TOKEN_MANDATORY_LABEL(); til.Label.Sid = pin.AddrOfPinnedObject(); til.Label.Attributes = SE_GROUP_INTEGRITY; if (!SetTokenInformation(token, TokenIntegrityLevel, ref til, (uint)Marshal.SizeOf(typeof(TOKEN_MANDATORY_LABEL)))) Fail("SetTokenInformation"); STARTUPINFO si = new STARTUPINFO(); si.cb = Marshal.SizeOf(typeof(STARTUPINFO)); si.dwFlags = (int)STARTF_USESTDHANDLES; si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); si.hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE); si.hStdError = GetStdHandle(STD_ERROR_HANDLE); StringBuilder cmd = new StringBuilder(cmdLine); if (!CreateProcessAsUserW(token, exe, cmd, IntPtr.Zero, IntPtr.Zero, true, 0, IntPtr.Zero, null, ref si, out pi)) Fail("CreateProcessAsUser"); SetConsoleCtrlHandler(ignore, true); WaitForSingleObject(pi.hProcess, INFINITE); uint code; if (!GetExitCodeProcess(pi.hProcess, out code)) Fail("GetExitCodeProcess"); return unchecked((int)code); } finally { SetConsoleCtrlHandler(ignore, false); GC.KeepAlive(ignore); pin.Free(); if (pi.hThread != IntPtr.Zero) CloseHandle(pi.hThread); if (pi.hProcess != IntPtr.Zero) CloseHandle(pi.hProcess); if (token != IntPtr.Zero) CloseHandle(token); if (safer != IntPtr.Zero) SaferCloseLevel(safer); } } } '@ $md5 = [System.Security.Cryptography.MD5]::Create() $hash = -join ($md5.ComputeHash([Text.Encoding]::UTF8.GetBytes($nativeSource)) | ForEach-Object { $_.ToString('x2') }) $nativeTypeName = "DropNative_$($hash.Substring(0, 12))" $native = $nativeTypeName -as [type] if (-not $native) { Add-Type -TypeDefinition ($nativeSource -replace 'public static class DropNative\b', "public static class $nativeTypeName") $native = $nativeTypeName -as [type] } # Quote one argument the way CreateProcess/CommandLineToArgvW expects # (equivalent of quote_arg in busybox-w32). function ConvertTo-CommandLineArg([string]$a) { if ($a.Length -gt 0 -and $a -notmatch '[\s"]') { return $a } $sb = New-Object System.Text.StringBuilder [void]$sb.Append('"') $bs = 0 foreach ($ch in $a.ToCharArray()) { if ($ch -eq '\') { $bs++; continue } if ($ch -eq '"') { [void]$sb.Append('\', 2 * $bs + 1); $bs = 0 } else { [void]$sb.Append('\', $bs); $bs = 0 } [void]$sb.Append($ch) } [void]$sb.Append('\', 2 * $bs).Append('"') return $sb.ToString() } # Locate an executable like find_first_executable: PATH + PATHEXT lookup. function Find-Executable([string]$name) { $c = Get-Command $name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($c) { return $c.Source } return $null } # Shells we recognise when walking up the process tree. $knownShells = 'pwsh.exe', 'powershell.exe', 'cmd.exe', 'bash.exe', 'sh.exe', 'zsh.exe', 'fish.exe', 'nu.exe', 'busybox.exe' function Get-ParentProcessId([int]$id) { (Get-CimInstance Win32_Process -Filter "ProcessId=$id" -ErrorAction SilentlyContinue).ParentProcessId } function Get-ProcessPath([int]$id) { $p = Get-Process -Id $id -ErrorAction SilentlyContinue if ($p -and $p.Path) { return $p.Path } (Get-CimInstance Win32_Process -Filter "ProcessId=$id" -ErrorAction SilentlyContinue).ExecutablePath } # Find the shell the user is sitting in. If this PowerShell was started only to # run this script (its command line names the script, as `-File drop.ps1`, # `.\drop.ps1`, or `-Command "& 'C:\...\drop.ps1' ..."`), skip it and walk up # the ancestors looking for a known shell. Otherwise this PowerShell is the # shell. Falls back to this PowerShell if nothing recognisable is found. # Set DROP_DEBUG=1 to see how the shell was chosen, on stderr. function Write-DropDebug([string]$Message) { if ($env:DROP_DEBUG) { [Console]::Error.WriteLine("drop: $Message") } } function Get-InvokingShell { $self = [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName $scriptName = [regex]::Escape([IO.Path]::GetFileName($PSCommandPath)) $hostArgs = [Environment]::GetCommandLineArgs() | Select-Object -Skip 1 $isWrapper = [bool]($hostArgs | Where-Object { $_ -match "(?i)(^|[\\/\s'`"&])$scriptName($|[\s'`"])" }) Write-DropDebug "host=$self wrapper=$isWrapper args=[$($hostArgs -join ' ')]" if (-not $isWrapper) { return $self } # POSIX shells export SHELL. It names the user's real shell even when the # nearest ancestor is a wrapper such as a `#!/bin/sh` shim. $fromEnv = $null if ($env:SHELL) { $sh = Find-Executable ([IO.Path]::GetFileNameWithoutExtension($env:SHELL)) if ($sh -and ($knownShells -contains [IO.Path]::GetFileName($sh).ToLower())) { $fromEnv = $sh } } Write-DropDebug "SHELL=[$env:SHELL] -> $fromEnv" $id = $PID $shWrapper = $null for ($depth = 0; $depth -lt 6; $depth++) { $id = Get-ParentProcessId $id if (-not $id) { break } $path = Get-ProcessPath $id Write-DropDebug "ancestor $depth pid=$id $path" if (-not $path) { continue } $name = [IO.Path]::GetFileName($path).ToLower() if ($knownShells -notcontains $name) { continue } if ($name -eq 'sh.exe' -and $fromEnv -notmatch '(^|[\\/])sh\.exe$') { # sh.exe is usually a shim's interpreter (Scoop, npm, ...) rather # than the shell the user is typing in. Remember it and keep # looking. Inside MSYS (Git bash), where a non-login shell does not # export SHELL, assume the bash.exe next to it is the real shell. $shWrapper = $path if (-not $fromEnv -and $env:MSYSTEM) { $sibling = Join-Path (Split-Path $path -Parent) 'bash.exe' if (Test-Path -LiteralPath $sibling) { $fromEnv = $sibling } } continue } return $path } # No live shell ancestor (e.g. run via the shebang from Git bash, where the # forking bash has already exec'd away): fall back to SHELL, then to sh. Write-DropDebug "no shell ancestor; fromEnv=$fromEnv shWrapper=$shWrapper" if ($fromEnv) { return $fromEnv } if ($shWrapper) { return $shWrapper } return $self } # --- argument handling (no param block so child flags like -NoProfile pass through) --- $rest = @($args) $cmdString = $null $optShell = $null while ($rest.Count -ge 1 -and $rest[0] -in '-c', '-s') { if ($rest.Count -lt 2) { [Console]::Error.WriteLine("drop: $($rest[0]) requires an argument"); exit 1 } if ($rest[0] -eq '-c') { $cmdString = $rest[1] } else { $optShell = $rest[1] } $rest = @($rest | Select-Object -Skip 2) } if ($rest.Count -eq 0 -or $null -ne $cmdString) { if ($optShell) { $exe = if ($optShell -match '[\\/]') { $optShell } else { Find-Executable $optShell } if (-not $exe -or -not (Test-Path -LiteralPath $exe -PathType Leaf)) { [Console]::Error.WriteLine("drop: can't find '$optShell'"); exit 127 } } else { $exe = Get-InvokingShell } $argv = @([IO.Path]::GetFileName($exe)) if ($null -ne $cmdString) { $opt = if ([IO.Path]::GetFileName($exe) -ieq 'cmd.exe') { '/c' } else { '-c' } $argv += $opt, $cmdString } $argv += $rest } else { $exe = Find-Executable $rest[0] if (-not $exe) { [Console]::Error.WriteLine("drop: can't find '$($rest[0])'"); exit 127 } $argv = @($rest) } $cmdLine = ($argv | ForEach-Object { ConvertTo-CommandLineArg $_ }) -join ' ' try { $code = $native::Run($exe, $cmdLine) } catch { $msg = $_.Exception.Message if ($_.Exception.InnerException) { $msg = $_.Exception.InnerException.Message } [Console]::Error.WriteLine("drop: can't execute '$exe': $msg") exit 126 } exit $code |